chore: sync uncommitted changes from previous sessions

Version bump to 0.1.0, updated app icons, migration fixes, various
Vue component improvements, CLAUDE.md project instructions, gitignore
for test artifacts, and webpack/main.js configuration updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-04-09 20:54:41 +02:00
parent bcb7bd7056
commit bfda98e678
21 changed files with 762 additions and 106 deletions
+5
View File
@@ -27,3 +27,8 @@ package-lock.json
# Docker # Docker
.build_done .build_done
# Test artifacts
.playwright-mcp/
screenshots/
test-results/
+64
View File
@@ -0,0 +1,64 @@
# CLAUDE.md
## Project Overview
Nextcloud 28 app (PHP backend, Vue 3 frontend) for managing scout group (Pfadfinderverein) members, families, fees, camps, and more.
## Tech Stack
- **Backend**: PHP 8.1+, Nextcloud OCP APIs, MariaDB, Doctrine DBAL via `IDBConnection`
- **Frontend**: Vue 3 (Composition API, `<script setup>`), Pinia stores, Vue Router (hash history), `@nextcloud/vue` v9, webpack
- **Build**: `npx webpack --node-env production`, output to `js/`
- **Deploy**: `make redeploy` copies app into running Nextcloud container. Version bump in `appinfo/info.xml` required to bust browser cache (`?v=` hash).
## Critical @nextcloud/vue v9 Gotchas
These caused most bugs in this project. Every contributor must know them:
1. **NcTextField**: Use `:model-value` / `@update:model-value` (Vue 3). NOT `:value` / `@update:value` / `:value.sync` (Vue 2). Fields will appear blank or not emit changes otherwise.
2. **NcSelect**: Always pass `:reduce="o => o.value"` and `label="label"` when options are objects `{value, label}`. Without `:reduce`, the select can't match string model-values to option objects.
3. **NcButton**: Has `overflow: hidden` and `padding: 0` in scoped styles. Global overrides via `<style>` blocks in `.vue` files are unreliable (css-loader may drop rules). Use a `<style>` tag injected from `main.js` after mount instead.
4. **appName/appVersion**: The library reads these two ways: (a) bare global identifier `appName` (use webpack DefinePlugin), (b) Vue `inject('appName')` (use `app.provide()`). Both are needed.
5. **loadState("core","apps")**: Nextcloud 28 returns an object, not an array. `@nextcloud/vue` calls `.find()` on it, which crashes. Patch in `main.js` with `Object.values()` before Vue mounts.
## Code Conventions
### Vue Components
- Always use `<script setup>` with Composition API
- German UI labels with proper Umlauts (Wölflinge, Männlich, Zusätzliche Notizen, etc.)
- Validation: show errors inline during editing, disable submit button until valid
- Emit pattern for form components: `$emit('update', fieldName, value)` — parent handles via `onFormUpdate(field, value)`
### Backend (PHP)
- Controllers extend `OCP\AppFramework\Controller`, return `JSONResponse`
- Services handle business logic, Mappers handle DB queries (Nextcloud ORM pattern)
- Migrations in `lib/Migration/`, class name format `VersionNNNNNNDateYYYYMMDDHHMMSS`
- Use `IQueryBuilder::PARAM_STR` for null values (not `PARAM_NULL` which doesn't exist)
- Wire `AuditService->logCreate/logUpdate/logDelete` into all CRUD service methods
### Stores (Pinia)
- One store per entity (`stores/members.js`, `stores/families.js`, etc.)
- Fetch via `@nextcloud/axios` + `generateUrl()`
- Always expose `clearError()` action
## UI Guidelines
- Buttons must show full text, never truncated. Global fix in `main.js` handles NcButton overflow.
- Sidebar active item must have rounded corners (global fix in `main.js`).
- Sensitive reports/actions use red styling (red border, red text) with tooltip explanation instead of overlapping badges.
- Use the global `SearchBar` component for member search, not inline NcTextField duplicates.
- Tables should show all columns relevant to displayed filters (e.g. if "Geburtstage diesen Monat" filter exists, show Geburtsdatum column).
- Default sensible values for new records (e.g. Eintrittsdatum = today, Status = aktiv, Rolle = mitglied).
## Deployment Notes
- `make deploy` = full clean deploy (build + fresh containers + install NC + copy app + enable)
- `make redeploy` = rebuild JS + copy into running container
- After redeploy without version bump, restart container (`docker compose restart nextcloud`) and hard-refresh browser
- Version bump (`appinfo/info.xml` + `webpack.config.js` DefinePlugin + `main.js` provide) + `occ upgrade` is the reliable way to bust all caches
## Gitea
- Repo: `shahondin1624/Mitgliederverwaltung` on `git.shahondin1624.de`
- Issues use labels: `enhancement`, `bug`, `backend`, `frontend`, `security`, `epic`, `priority:high/medium/low`
- Close issues via commit message: `(Closes #N)`
+1 -1
View File
@@ -5,7 +5,7 @@
<name>Mitgliederverwaltung</name> <name>Mitgliederverwaltung</name>
<summary>Mitgliederverwaltung für Pfadfindervereine</summary> <summary>Mitgliederverwaltung für Pfadfindervereine</summary>
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description> <description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
<version>0.0.3</version> <version>0.1.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>shahondin1624</author> <author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace> <namespace>Mitgliederverwaltung</namespace>
+421
View File
@@ -0,0 +1,421 @@
# Integration Test Plan & Report -- Mitgliederverwaltung
**Test Plan:** [Gitea Issue #123](https://git.shahondin1624.de/shahondin1624/Mitgliederverwaltung/issues/123)
**Execution Date:** 2026-04-09
**Tester:** Claude (Playwright interactive testing)
**Environment:** Nextcloud 28, localhost:8080, admin user
**App Version:** 0.0.3
---
## Test Plan (from Issue #123)
This test plan covers black-box UI testing of all features against the requirements spec (`docs/requirements.md`). Testing uses Playwright MCP tools to interact with the UI as an admin user.
### Phases
| Phase | Area | Status |
|-------|------|--------|
| 0 | Build & Deployment | PASS |
| 1 | Navigation & App Shell | PASS (partial) |
| 2 | Member Management | FAIL (blocked by #125) |
| 3 | Family Management | PASS |
| 4 | Stufen Management | PARTIAL |
| 5 | Fee System | PASS (with issues) |
| 6 | Search & Filtering | PARTIAL |
| 7 | Reports & Exports | PASS |
| 8 | Lagertracking | PASS (empty state) |
| 9 | Injury Tracking | PASS (empty state) |
| 10 | Audit Log | FAIL |
| 11 | Settings & Permissions | PASS (with issues) |
| 12 | Soft Deletion & DSGVO | NOT TESTED |
| 13 | Milestones | NOT TESTED |
| 14 | Data Validation Edge Cases | NOT TESTED (blocked by #125) |
### Linked Issues
| Issue | Title | Priority | Status |
|-------|-------|----------|--------|
| #125 | Member detail form fields render blank | high | open |
| #126 | Audit log not recording any events | high | open |
| #127 | Fee table shows member IDs instead of names | medium | open |
| #128 | No default Stufen seeded on install | low | open |
| #129 | Missing Lagerhistorie and Verletzungsprotokoll reports | low | open |
| #130 | TypeError: e.find is not a function | low | open |
| #131 | @nextcloud/vue appName/appVersion not configured | low | open |
| #133 | Inline member search does not filter | medium | open |
| #134 | Stufe column shows raw stufeId instead of name | medium | open |
| #135 | Add missing pages to sidebar navigation | medium | open |
| #136 | UX: Various minor UI polish issues | low | open |
---
## Executive Summary
The Mitgliederverwaltung app has a solid architecture with many features already implemented. However, there is a **critical, pervasive bug** affecting all `NcTextField` form fields: values from the Vue store are not rendered into the form inputs. This affects member detail view, new member creation, Stufe name display in settings, and likely other form-based features. Additionally, the inline member search does not filter results.
---
## 1. Member Management
### 1.1 Member List (`#/`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads with member table | PASS | 5 members displayed correctly |
| Columns: Name, Stufe, Status, Alter, Rolle | PASS | All visible |
| Filter: Alle | PASS | Shows all 5 members |
| Filter: Aktive | PASS | Hides inactive Thomas Schmidt, shows 4 |
| Filter: Inaktive | PASS | Shows only Thomas Schmidt |
| Filter: Unbezahlte Beitraege | PASS | Button renders (not tested with data) |
| Filter: Geburtstage diesen Monat | PASS | Button renders (not tested with data) |
| Sorting by Name | PASS | Default sort ascending, toggleable |
| Inline search | **FAIL** | Typing "Weber" does not filter the list. `NcTextField` `@update:value` event does not fire properly, so `store.searchMembers()` is never called |
| Click row navigates to detail | PASS | Routes to `#/members/:id` |
| Pagination | PASS | Implemented but not visible with only 5 members |
#### Bugs
- **BUG-001 (Critical)**: Inline search field does not filter the member list. The `NcTextField` uses `:value.sync` (Vue 2 syntax) which may not work in Vue 3, and the `@update:value` event is not triggered by user input.
- **BUG-002 (Medium)**: Stufe column shows raw `stufeId` value (or "---") instead of the Stufe name. See `MemberList.vue:107`: `{{ member.stufeId || '---' }}` should resolve to Stufe name.
#### UX Issues
- **UX-001**: Two search bars visible -- one global SearchBar in the header and one inline NcTextField in the member list header. Confusing which to use. Consider removing one or clarifying their purpose.
- **UX-002**: "Neues Mitglied" button text truncates to "Neues ..." at certain viewport widths.
### 1.2 Member Detail (`#/members/:id`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Header shows member name | PASS | "Max Mustermann" displayed correctly |
| Info banner (Alter, Mitgliedsdauer, Status) | PASS | "13 Jahre", "6 Jahre, 3 Monate", green "Aktiv" badge |
| Tab navigation (7 tabs) | PASS | Persoenlich, Familie, Beitrag, Lager, Verletzungen, Dateien, Verlauf |
| Persoenlich tab -- form fields display | **FAIL** | All text fields are empty despite store having correct data |
| Edit mode toggle | PASS | "Bearbeiten" enables fields, shows "Speichern"/"Abbrechen" |
| Edit mode -- sub-entity buttons appear | PASS | "Adresse hinzufuegen", "Telefonnummer hinzufuegen", "E-Mail hinzufuegen" |
| Zurueck button | PASS | Navigates back to member list |
| Familie tab | NOT IMPL | Placeholder: "Wird in einem spaeteren Milestone implementiert." |
| Beitrag tab | NOT IMPL | Placeholder |
| Lager tab | NOT IMPL | Placeholder |
| Verletzungen tab | NOT IMPL | Placeholder |
| Dateien tab | PASS | FileExplorer works, shows folder path, offers "Ordner erstellen" |
| Verlauf tab | NOT IMPL | Placeholder |
#### Bugs
- **BUG-003 (Critical)**: All form fields in MemberForm.vue display empty values in both view and edit mode. The `NcTextField` `:value` prop does not render the bound data. The Vue store (`members.currentMember`) has correct data (`{vorname: "Max", nachname: "Mustermann", ...}`) but inputs show empty. Root cause: likely `NcTextField` expects `:modelValue` instead of `:value` in the Nextcloud Vue 8.x library used with Vue 3.
- **BUG-004 (Critical)**: Saving a new member fails with 400 "Missing required fields: vorname, nachname, geburtsdatum, eintritt" because the `NcTextField` `@update:value` events don't fire, so `formData` stays at initial empty values even though the user typed into the fields.
### 1.3 New Member (`#/members/new`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Form renders in edit mode | PASS | All fields enabled |
| Status field disabled for new members | PASS | Correct behavior |
| Rolle dropdown works | PASS | Shows "Mitglied", "Erziehungsberechtigter" |
| Save new member | **FAIL** | 400 error due to BUG-004 |
#### UX Issues
- **UX-003**: "Erziehungsberechtigter" text breaks across lines in the NcSelect dropdown ("Erziehungsbe" / "rechtigter"). Dropdown needs `min-width` or `white-space: nowrap`.
---
## 2. Family Management
### 2.1 Family List (`#/families`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads | PASS | Shows "Keine Familien" empty state |
| Empty state with CTA | PASS | "Erste Familie anlegen" button shown |
| Search field | PASS | "Familien suchen..." placeholder |
| "Neue Familie" button | PASS | Visible but text may truncate |
### 2.2 Family Detail (`#/families/new`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Form loads | PASS | Familienname, Bankverbindung fields |
| Bankverbindung section | PASS | Kontoinhaber, IBAN (with example format), BIC, Kreditinstitut |
| Form buttons | PASS | Speichern, Abbrechen |
#### UX Issues
- **UX-004**: "Neue..." button text truncation on family list page.
---
## 3. Fee System (`#/fees`)
### 3.1 Fee Overview
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads with fee records | PASS | 3 records at 60.00 EUR each |
| Summary banner | PASS | Gesamt erwartet, Bezahlt, Ausstehend |
| Year selector | PASS | Dropdown with 2026, 2025 |
| "Beitraege berechnen" button | PASS | Visible and clickable |
| Mark fee as paid | PASS | Updates to "Ja", sets Zahlungsdatum, updates summary |
| "Betrag aendern" button | PASS | Visible (not tested action) |
| "Notiz" button | PASS | Visible (not tested action) |
| Erziehungsberechtigter excluded | PASS | Sabine Mustermann not in fee list |
| Inactive member excluded | PASS | Thomas Schmidt not in fee list |
#### Bugs
- **BUG-005 (Medium)**: Members display as "Mitglied #1", "Mitglied #3", "Mitglied #5" instead of actual names (e.g., "Max Mustermann"). The member name resolution is broken.
---
## 4. Lager (Camp) Management (`#/lager`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads | PASS | Shows table header and "Keine Lager gefunden" |
| Filter dropdowns | PASS | "Alle Jahre", "Alle Stufen" |
| "Neues Lager" button | PASS | Visible |
| Table columns | PASS | Name, Startdatum, Enddatum, Ort, Teilnehmer, Aktionen |
#### UX Issues
- **UX-005**: Empty state lacks an icon/illustration (unlike other empty states that use NcEmptyContent).
---
## 5. Injury Tracking (`#/injuries`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads | PASS | "Verletzungsprotokoll" with table |
| Date range filters | PASS | Two date pickers |
| "Neue Verletzung" button | PASS | Visible |
| Table columns | PASS | Datum, Mitglied, Lager/Aktivitaet, Beschreibung, Beteiligte, Aktionen |
#### UX Issues
- **UX-006**: Injuries page is not accessible from the sidebar navigation. It requires direct URL or is only reachable from the member detail tab. Consider adding it to the sidebar.
---
## 6. Reports (`#/reports`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Report cards grid | PASS | 8 report types in 2x4 grid |
| Report types | PASS | Mitgliederliste, Beitragsliste, Stufenliste, Allergieliste, Geburtstagsliste, Kontaktliste, Bankverbindungen, Familienliste |
| "Sensibel" badge on Bankverbindungen | PASS | Nice security awareness feature |
| Click report shows filter | PASS | Status filter dropdown appears |
| Preview (Vorschau) | PASS | Shows member table with correct data |
| Preview data formatting | PASS | German date format (DD.MM.YYYY) |
| Download buttons | PASS | PDF, CSV, Verschluesselt options shown |
#### UX Issues
- **UX-007**: "Bankverbindungen" text truncated on the card ("Bankverbindung...").
---
## 7. Import Wizard (`#/import`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads with step indicator | PASS | 5 steps: Hochladen, Zuordnung, Vorschau, Dry-Run, Ergebnis |
| File upload field | PASS | "Choose file" button with file input |
| Separator/encoding options | PASS | Trennzeichen (Komma), Kodierung (UTF-8) |
#### UX Issues
- **UX-008**: "Choose file" button shows English text (browser default). Consider using a custom styled button with German label "Datei auswaehlen".
- **UX-009**: Import page not accessible from sidebar navigation. Only accessible via direct URL.
---
## 8. Query Builder (`#/queries`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads | PASS | "Abfrage-Builder" with saved queries panel |
| AND/OR toggle | PASS | UND/ODER buttons |
| Add condition | PASS | Creates row with field/operator/value |
| Default condition | PASS | "Vorname ist gleich Wert..." |
| Action buttons | PASS | Abfrage ausfuehren, Speichern, Zuruecksetzen |
| Saved queries panel | PASS | "Noch keine gespeicherten Abfragen" |
| Add group | PASS | Button present (not tested) |
#### UX Issues
- **UX-010**: Query builder page not accessible from sidebar navigation.
---
## 9. Audit Log (`#/audit-log`)
| Test Case | Status | Notes |
|-----------|--------|-------|
| Page loads | PASS | Filter panel with 5 filter fields |
| Filters: Entitaet, Benutzer, Entitaet-ID, Von, Bis | PASS | All rendered correctly |
| "Filter zuruecksetzen" button | PASS | Visible |
| Empty state | PASS | "Keine Eintraege" with helpful message |
#### Bugs
- **BUG-006 (Medium)**: No audit entries logged despite performing state-changing operations (marking fee as paid). Audit logging may not be connected to fee operations, or there's a backend issue.
---
## 10. Settings (`#/settings`)
### 10.1 Stufen verwalten
| Test Case | Status | Notes |
|-----------|--------|-------|
| Stufe list renders | PASS | 3 Stufen with age ranges and colors |
| Age range fields | PASS | 6-12, 12-17, 18-99 |
| Color pickers | PASS | Yellow, Green, Red |
| Reorder arrows | PASS | Up/down arrows visible |
| "Neue Stufe" button | PASS | Visible |
| Member count per Stufe | PASS | Shows 0 for all (no members assigned) |
#### Bugs
- **BUG-007 (Medium)**: Stufe name fields appear empty (same NcTextField binding issue as BUG-003). Names were configured but don't display.
### 10.2 Beitragsregeln
| Test Case | Status | Notes |
|-----------|--------|-------|
| Fee rule form | PASS | Gueltig ab Jahr, Grundbeitrag fields |
| Preview calculation | PASS | Shows 1. Kind 60 EUR, 2. Kind 45 EUR, Gesamt 105 EUR |
| Existing rules table | PASS | Shows 2026 rule (60.00 EUR) |
| "Regel speichern" button | PASS | Visible |
### 10.3 Berechtigungen verwalten
| Test Case | Status | Notes |
|-----------|--------|-------|
| User list | PASS | Shows admin user |
| User filter | PASS | "Benutzer filtern..." search field |
| Access level dropdown | PASS | "Kein Zugriff" shown for admin |
| "Bankdaten sichtbar" checkbox | PASS | Visible, unchecked |
#### UX Issues
- **UX-011**: Admin user shows "Kein Zugriff" (No Access) as default permission. This is confusing since the admin clearly has access. Consider auto-assigning admin role or showing a different default.
---
## 11. Navigation & Global UX
### 11.1 Sidebar Navigation
| Item | Status | Route |
|------|--------|-------|
| Mitglieder | PASS | `#/` |
| Familien | PASS | `#/families` |
| Beitraege | PASS | `#/fees` |
| Lager | PASS | `#/lager` |
| Berichte | PASS | `#/reports` |
| Audit-Log | PASS | `#/audit-log` |
| Einstellungen | PASS | `#/settings` |
#### Missing from sidebar:
- Verletzungen (`#/injuries`)
- Import (`#/import`)
- Abfragen (`#/queries`)
### 11.2 Global Search (SearchBar component)
The global SearchBar in the header is a separate, API-powered search with a dropdown results panel. It supports keyboard navigation, minimum 2-character input, and navigates to member detail on selection. This is distinct from the broken inline search in MemberList.
### 11.3 Console Errors
| Error | Severity | Description |
|-------|----------|-------------|
| `@nextcloud/vue: appName not set` | Low | Configuration warning -- set appName in webpack config |
| `@nextcloud/vue: appVersion not set` | Low | Configuration warning -- set appVersion in webpack config |
| `TypeError: e.find is not a function` | Medium | Occurs on initial page load, likely in a store/component setup function trying to call `.find()` on a non-array value |
---
## 12. Not Implemented (Placeholder Tabs)
The following features show "Wird in einem spaeteren Milestone implementiert":
- Member detail: Familie tab (linking member to family from detail view)
- Member detail: Beitrag tab (showing fee history per member)
- Member detail: Lager tab (showing camp participation per member)
- Member detail: Verletzungen tab (showing injuries per member)
- Member detail: Verlauf tab (showing change history per member)
---
## 13. Summary of Findings
### Critical Bugs (Must Fix)
| ID | Description | Impact |
|----|-------------|--------|
| BUG-003 | NcTextField `:value` prop does not display data | All form views show empty fields |
| BUG-004 | NcTextField `@update:value` does not emit on input | Cannot save new members or edit existing ones |
**Root Cause Analysis:** Both BUG-003 and BUG-004 stem from the same issue. The app uses `NcTextField` with `:value` and `@update:value`, but the Nextcloud Vue 8.x library for Vue 3 likely expects `:modelValue` and `@update:modelValue`. This is the migration pattern from Vue 2 to Vue 3. The fix would be to update all `NcTextField` usages:
```vue
<!-- Before (broken) -->
<NcTextField :value="member.vorname" @update:value="..." />
<!-- After (fixed) -->
<NcTextField :model-value="member.vorname" @update:model-value="..." />
```
Similarly, in `MemberList.vue`, the `:value.sync` modifier is Vue 2 syntax that doesn't work in Vue 3.
### Medium Bugs
| ID | Description |
|----|-------------|
| BUG-001 | Inline member search does not filter the list |
| BUG-002 | Stufe column shows raw ID instead of name |
| BUG-005 | Fee overview shows "Mitglied #N" instead of member names |
| BUG-006 | Audit log shows no entries despite state changes |
| BUG-007 | Stufe names not displayed in settings |
### UX Improvements
| ID | Description | Priority |
|----|-------------|----------|
| UX-001 | Remove duplicate search bar or clarify purpose | Medium |
| UX-002 | Fix "Neues Mitglied" button text truncation | Low |
| UX-003 | Fix "Erziehungsberechtigter" text wrapping in dropdown | Low |
| UX-004 | Fix "Neue..." button text truncation on family list | Low |
| UX-005 | Add empty state icon to Lager list | Low |
| UX-006 | Add Injuries to sidebar navigation | Medium |
| UX-007 | Fix "Bankverbindungen" text truncation on report card | Low |
| UX-008 | Localize "Choose file" button to German | Low |
| UX-009 | Add Import to sidebar navigation | Medium |
| UX-010 | Add Query Builder to sidebar navigation | Medium |
| UX-011 | Clarify admin permission default in settings | Medium |
### Working Features (Verified)
- Member list with filter chips (Alle, Aktive, Inaktive, etc.)
- Member list sorting by all columns
- Member detail header with derived fields (age, membership duration)
- Member detail tab navigation
- Member detail Files tab (FileExplorer with Nextcloud Files integration)
- Family list with empty state and creation form
- Fee overview with summary, year selection, and payment tracking
- Lager list with filters
- Injury tracking list with date range filters
- Reports with 8 types, preview, and PDF/CSV/encrypted download
- Import wizard with 5-step process
- Query builder with AND/OR logic, conditions, groups, and saved queries
- Audit log with comprehensive filters
- Settings with Stufe management, fee rules, and permission management
- Global search (SearchBar) with API-powered dropdown results
- Sidebar navigation for main sections
---
## 14. Remaining Test Plan (Not Yet Executed)
The following phases from the test plan (Issue #123) were not tested in this session, either because they were blocked by #125 or require more complex setup:
### Phase 12: Soft Deletion & DSGVO (Req 4.5, 4.6)
- [ ] Soft-delete member -> status=geloescht
- [ ] Hidden from normal views
- [ ] Archiv view shows deleted members (Admin only)
- [ ] Sensitive data hard-deleted on soft-delete
- [ ] DSGVO export (Auskunftsrecht)
- [ ] DSGVO full delete (Recht auf Loeschung)
### Phase 13: Milestones (Req 2.8)
- [ ] Milestones view -> "Anstehende Jubilaeen"
- [ ] Milestone thresholds configurable (25, 50, 60, 70 years)
### Phase 14: Data Validation Edge Cases (Req 4.4)
Blocked by #125 -- cannot test form validation when forms don't accept input.
- [ ] Invalid IBAN -> rejection with error
- [ ] Phone without E.164 -> rejection
- [ ] Birthday > 120 years ago -> rejection
- [ ] Eintritt before Geburtsdatum -> rejection
- [ ] Duplicate member detection (same Vorname+Nachname+Geburtsdatum)
### Deeper Testing (after #125 is fixed)
- [ ] Create member via UI and verify persistence
- [ ] Edit member and verify changes persist
- [ ] Delete member and verify soft-delete behavior
- [ ] Add addresses, phones, emails to member
- [ ] Create family and link members
- [ ] IBAN validation on family banking
- [ ] Assign member to Stufe and verify history
- [ ] Save and load queries in query builder
- [ ] Run complex queries (Status=aktiv AND Alter<14)
- [ ] Download PDF/CSV reports and verify content
- [ ] Encrypted export with password prompt
- [ ] Lager creation with participant management
- [ ] Injury creation from member detail
+10 -10
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Member management icon: three people silhouettes (dark mode) --> <!-- Group/members icon (dark theme variant) -->
<!-- Center person (front) --> <!-- Left person -->
<circle cx="16" cy="10" r="4" fill="#ffffff"/> <circle cx="7" cy="11" r="3" fill="#eee"/>
<path d="M10 24c0-3.3 2.7-6 6-6s6 2.7 6 6v2H10v-2z" fill="#ffffff"/> <path d="M7 15.5c-3 0-5.5 1.8-5.5 4v1.5c0 .6.4 1 1 1h9c.6 0 1-.4 1-1v-1.5c0-2.2-2.5-4-5.5-4z" fill="#eee"/>
<!-- Left person (back) --> <!-- Center person (larger, in front) -->
<circle cx="8" cy="12" r="3" fill="#aaaaaa"/> <circle cx="16" cy="8" r="3.8" fill="#eee"/>
<path d="M3.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#aaaaaa"/> <path d="M16 13.2c-3.6 0-6.5 2.1-6.5 4.8v2c0 .6.4 1 1 1h11c.6 0 1-.4 1-1v-2c0-2.7-2.9-4.8-6.5-4.8z" fill="#eee"/>
<!-- Right person (back) --> <!-- Right person -->
<circle cx="24" cy="12" r="3" fill="#aaaaaa"/> <circle cx="25" cy="11" r="3" fill="#eee"/>
<path d="M19.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#aaaaaa"/> <path d="M25 15.5c-3 0-5.5 1.8-5.5 4v1.5c0 .6.4 1 1 1h9c.6 0 1-.4 1-1v-1.5c0-2.2-2.5-4-5.5-4z" fill="#eee"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 708 B

+10 -10
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Member management icon: three people silhouettes --> <!-- Group/members icon inspired by flaticon.com/de/kostenloses-icon/gruppe_681443 -->
<!-- Center person (front) --> <!-- Left person -->
<circle cx="16" cy="10" r="4" fill="#1a1a1a"/> <circle cx="7" cy="11" r="3" fill="#222"/>
<path d="M10 24c0-3.3 2.7-6 6-6s6 2.7 6 6v2H10v-2z" fill="#1a1a1a"/> <path d="M7 15.5c-3 0-5.5 1.8-5.5 4v1.5c0 .6.4 1 1 1h9c.6 0 1-.4 1-1v-1.5c0-2.2-2.5-4-5.5-4z" fill="#222"/>
<!-- Left person (back) --> <!-- Center person (larger, in front) -->
<circle cx="8" cy="12" r="3" fill="#666"/> <circle cx="16" cy="8" r="3.8" fill="#222"/>
<path d="M3.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#666"/> <path d="M16 13.2c-3.6 0-6.5 2.1-6.5 4.8v2c0 .6.4 1 1 1h11c.6 0 1-.4 1-1v-2c0-2.7-2.9-4.8-6.5-4.8z" fill="#222"/>
<!-- Right person (back) --> <!-- Right person -->
<circle cx="24" cy="12" r="3" fill="#666"/> <circle cx="25" cy="11" r="3" fill="#222"/>
<path d="M19.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#666"/> <path d="M25 15.5c-3 0-5.5 1.8-5.5 4v1.5c0 .6.4 1 1 1h9c.6 0 1-.4 1-1v-1.5c0-2.2-2.5-4-5.5-4z" fill="#222"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 611 B

After

Width:  |  Height:  |  Size: 746 B

@@ -124,9 +124,9 @@ class Version000004Date20260407000003 extends SimpleMigrationStep {
} }
$defaults = [ $defaults = [
['name' => 'Woelflinge', 'sort_order' => 1, 'age_range_min' => 7, 'age_range_max' => 10, 'color' => '#FFA500'], ['name' => 'Wölflinge', 'sort_order' => 1, 'age_range_min' => 7, 'age_range_max' => 10, 'color' => '#e8fd09'],
['name' => 'Pfadfinder', 'sort_order' => 2, 'age_range_min' => 11, 'age_range_max' => 15, 'color' => '#2196F3'], ['name' => 'Pfadfinder', 'sort_order' => 2, 'age_range_min' => 11, 'age_range_max' => 15, 'color' => '#0c800c'],
['name' => 'Rover', 'sort_order' => 3, 'age_range_min' => 16, 'age_range_max' => 25, 'color' => '#4CAF50'], ['name' => 'Rover', 'sort_order' => 3, 'age_range_min' => 16, 'age_range_max' => 25, 'color' => '#dd1212'],
]; ];
foreach ($defaults as $stufe) { foreach ($defaults as $stufe) {
@@ -42,25 +42,25 @@ class Version000014Date20260409000000 extends SimpleMigrationStep {
$defaultStufen = [ $defaultStufen = [
[ [
'name' => 'Woelflinge', 'name' => 'Wölflinge',
'sort_order' => 0, 'sort_order' => 0,
'age_range_min' => 7, 'age_range_min' => 6,
'age_range_max' => 10, 'age_range_max' => 11,
'color' => '#FF8C00', 'color' => '#FFD700',
], ],
[ [
'name' => 'Pfadfinder', 'name' => 'Pfadfinder',
'sort_order' => 1, 'sort_order' => 1,
'age_range_min' => 10, 'age_range_min' => 12,
'age_range_max' => 16, 'age_range_max' => 17,
'color' => '#228B22', 'color' => '#228B22',
], ],
[ [
'name' => 'Rover', 'name' => 'Rover',
'sort_order' => 2, 'sort_order' => 2,
'age_range_min' => 16, 'age_range_min' => 18,
'age_range_max' => null, 'age_range_max' => null,
'color' => '#4169E1', 'color' => '#DD1212',
], ],
]; ];
@@ -70,8 +70,8 @@ class Version000014Date20260409000000 extends SimpleMigrationStep {
->values([ ->values([
'name' => $qb->createNamedParameter($stufe['name']), 'name' => $qb->createNamedParameter($stufe['name']),
'sort_order' => $qb->createNamedParameter($stufe['sort_order'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT), 'sort_order' => $qb->createNamedParameter($stufe['sort_order'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'age_range_min' => $qb->createNamedParameter($stufe['age_range_min'], $stufe['age_range_min'] !== null ? \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT : \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_NULL), 'age_range_min' => $qb->createNamedParameter($stufe['age_range_min'], $stufe['age_range_min'] !== null ? \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT : \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
'age_range_max' => $qb->createNamedParameter($stufe['age_range_max'], $stufe['age_range_max'] !== null ? \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT : \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_NULL), 'age_range_max' => $qb->createNamedParameter($stufe['age_range_max'], $stufe['age_range_max'] !== null ? \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT : \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR),
'color' => $qb->createNamedParameter($stufe['color']), 'color' => $qb->createNamedParameter($stufe['color']),
]); ]);
$qb->executeStatement(); $qb->executeStatement();
+105 -12
View File
@@ -2,10 +2,30 @@
<div class="family-form"> <div class="family-form">
<div class="family-form__row"> <div class="family-form__row">
<label class="family-form__label">Familienname *</label> <label class="family-form__label">Familienname *</label>
<NcTextField :value="family.name" <NcTextField :model-value="family.name"
:disabled="!editing" :disabled="!editing"
placeholder="z.B. Mueller" placeholder="z.B. Mueller"
@update:value="$emit('update', 'name', $event)" /> @update:model-value="$emit('update', 'name', $event)" />
</div>
<div v-if="editing" class="family-form__row">
<label class="family-form__label">Oder aus Mitgliedern wählen</label>
<div class="family-form__member-picker">
<NcSelect :model-value="selectedMember"
:options="filteredMemberOptions"
:reduce="o => o.value"
label="label"
placeholder="Mitglied auswählen..."
:loading="membersLoading"
:clearable="true"
@update:model-value="onMemberSelected" />
<label class="family-form__filter-label">
<input v-model="hideAlreadyInFamily"
type="checkbox"
class="family-form__checkbox">
Nur Mitglieder ohne Familie anzeigen
</label>
</div>
</div> </div>
<!-- Banking section (only visible with banking permission) --> <!-- Banking section (only visible with banking permission) -->
@@ -14,19 +34,19 @@
<div class="family-form__row"> <div class="family-form__row">
<label class="family-form__label">Kontoinhaber</label> <label class="family-form__label">Kontoinhaber</label>
<NcTextField :value="family.kontoinhaberEncrypted || ''" <NcTextField :model-value="family.kontoinhaberEncrypted || ''"
:disabled="!editing" :disabled="!editing"
placeholder="Name des Kontoinhabers" placeholder="Name des Kontoinhabers"
@update:value="$emit('update', 'kontoinhaberEncrypted', $event)" /> @update:model-value="$emit('update', 'kontoinhaberEncrypted', $event)" />
</div> </div>
<div class="family-form__row"> <div class="family-form__row">
<label class="family-form__label">IBAN</label> <label class="family-form__label">IBAN</label>
<div class="family-form__iban-wrapper"> <div class="family-form__iban-wrapper">
<NcTextField :value="family.ibanEncrypted || ''" <NcTextField :model-value="family.ibanEncrypted || ''"
:disabled="!editing" :disabled="!editing"
placeholder="DE89 3704 0044 0532 0130 00" placeholder="DE89 3704 0044 0532 0130 00"
@update:value="onIbanInput" /> @update:model-value="onIbanInput" />
<span v-if="ibanValidation !== null" :class="ibanValidationClass"> <span v-if="ibanValidation !== null" :class="ibanValidationClass">
{{ ibanValidation }} {{ ibanValidation }}
</span> </span>
@@ -35,26 +55,28 @@
<div class="family-form__row"> <div class="family-form__row">
<label class="family-form__label">BIC</label> <label class="family-form__label">BIC</label>
<NcTextField :value="family.bic || ''" <NcTextField :model-value="family.bic || ''"
:disabled="!editing" :disabled="!editing"
placeholder="COBADEFFXXX" placeholder="COBADEFFXXX"
@update:value="$emit('update', 'bic', $event)" /> @update:model-value="$emit('update', 'bic', $event)" />
</div> </div>
<div class="family-form__row"> <div class="family-form__row">
<label class="family-form__label">Kreditinstitut</label> <label class="family-form__label">Kreditinstitut</label>
<NcTextField :value="family.kreditinstitut || ''" <NcTextField :model-value="family.kreditinstitut || ''"
:disabled="!editing" :disabled="!editing"
placeholder="Name der Bank" placeholder="Name der Bank"
@update:value="$emit('update', 'kreditinstitut', $event)" /> @update:model-value="$emit('update', 'kreditinstitut', $event)" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { NcTextField } from '@nextcloud/vue' import { NcTextField, NcSelect } from '@nextcloud/vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
const props = defineProps({ const props = defineProps({
family: { family: {
@@ -70,6 +92,58 @@ const props = defineProps({
const emit = defineEmits(['update']) const emit = defineEmits(['update'])
const ibanValidation = ref(null) const ibanValidation = ref(null)
const allMembers = ref([])
const membersLoading = ref(false)
const hideAlreadyInFamily = ref(true)
const selectedMember = ref(null)
onMounted(() => {
if (props.editing) {
loadAllMembers()
}
})
watch(() => props.editing, (val) => {
if (val && allMembers.value.length === 0) {
loadAllMembers()
}
})
async function loadAllMembers() {
membersLoading.value = true
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/members')
const response = await axios.get(url, { params: { limit: 9999, offset: 0 } })
allMembers.value = response.data.data || []
} catch {
allMembers.value = []
} finally {
membersLoading.value = false
}
}
const filteredMemberOptions = computed(() => {
let members = allMembers.value
if (hideAlreadyInFamily.value) {
members = members.filter(m => !m.familyId)
}
// Deduplicate by last name and build options
const seen = new Set()
const options = []
for (const m of members) {
const label = `${m.nachname}, ${m.vorname}`
options.push({ value: m.nachname, label })
seen.add(m.nachname)
}
return options
})
function onMemberSelected(nachname) {
selectedMember.value = nachname
if (nachname) {
emit('update', 'name', nachname)
}
}
const ibanValidationClass = computed(() => { const ibanValidationClass = computed(() => {
if (ibanValidation.value === null) return '' if (ibanValidation.value === null) return ''
@@ -181,4 +255,23 @@ watch(() => props.family.ibanEncrypted, (val) => {
color: var(--color-error); color: var(--color-error);
font-size: 0.85em; font-size: 0.85em;
} }
.family-form__member-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.family-form__filter-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
color: var(--color-text-lighter);
cursor: pointer;
}
.family-form__checkbox {
cursor: pointer;
}
</style> </style>
+28 -2
View File
@@ -7,14 +7,18 @@
<NcTextField :model-value="member.vorname" <NcTextField :model-value="member.vorname"
:disabled="!editing" :disabled="!editing"
:required="true" :required="true"
:error="!!errors.vorname"
@update:model-value="$emit('update', 'vorname', $event)" /> @update:model-value="$emit('update', 'vorname', $event)" />
<span v-if="editing && errors.vorname" class="member-form__error">{{ errors.vorname }}</span>
</div> </div>
<div class="member-form__field"> <div class="member-form__field">
<label>Nachname *</label> <label>Nachname *</label>
<NcTextField :model-value="member.nachname" <NcTextField :model-value="member.nachname"
:disabled="!editing" :disabled="!editing"
:required="true" :required="true"
:error="!!errors.nachname"
@update:model-value="$emit('update', 'nachname', $event)" /> @update:model-value="$emit('update', 'nachname', $event)" />
<span v-if="editing && errors.nachname" class="member-form__error">{{ errors.nachname }}</span>
</div> </div>
<!-- Row 2: Dates --> <!-- Row 2: Dates -->
@@ -24,12 +28,16 @@
type="date" type="date"
:disabled="!editing" :disabled="!editing"
:required="true" :required="true"
:error="!!errors.geburtsdatum"
@update:model-value="$emit('update', 'geburtsdatum', $event)" /> @update:model-value="$emit('update', 'geburtsdatum', $event)" />
<span v-if="editing && errors.geburtsdatum" class="member-form__error">{{ errors.geburtsdatum }}</span>
</div> </div>
<div class="member-form__field"> <div class="member-form__field">
<label>Geschlecht</label> <label>Geschlecht</label>
<NcSelect :model-value="member.geschlecht" <NcSelect :model-value="member.geschlecht"
:options="geschlechtOptions" :options="geschlechtOptions"
:reduce="o => o.value"
label="label"
:disabled="!editing" :disabled="!editing"
:clearable="true" :clearable="true"
@update:model-value="$emit('update', 'geschlecht', $event)" /> @update:model-value="$emit('update', 'geschlecht', $event)" />
@@ -40,13 +48,18 @@
<label>Rolle *</label> <label>Rolle *</label>
<NcSelect :model-value="member.rolle" <NcSelect :model-value="member.rolle"
:options="rolleOptions" :options="rolleOptions"
:reduce="o => o.value"
label="label"
:disabled="!editing" :disabled="!editing"
@update:model-value="$emit('update', 'rolle', $event)" /> @update:model-value="$emit('update', 'rolle', $event)" />
<span v-if="editing && errors.rolle" class="member-form__error">{{ errors.rolle }}</span>
</div> </div>
<div class="member-form__field"> <div class="member-form__field">
<label>Status</label> <label>Status</label>
<NcSelect :model-value="member.status" <NcSelect :model-value="member.status"
:options="statusOptions" :options="statusOptions"
:reduce="o => o.value"
label="label"
:disabled="!editing || isNew" :disabled="!editing || isNew"
@update:model-value="$emit('update', 'status', $event)" /> @update:model-value="$emit('update', 'status', $event)" />
</div> </div>
@@ -58,7 +71,9 @@
type="date" type="date"
:disabled="!editing" :disabled="!editing"
:required="true" :required="true"
:error="!!errors.eintritt"
@update:model-value="$emit('update', 'eintritt', $event)" /> @update:model-value="$emit('update', 'eintritt', $event)" />
<span v-if="editing && errors.eintritt" class="member-form__error">{{ errors.eintritt }}</span>
</div> </div>
<div class="member-form__field"> <div class="member-form__field">
<label>Austrittsdatum</label> <label>Austrittsdatum</label>
@@ -74,6 +89,8 @@
<label>Krankenversicherung Typ</label> <label>Krankenversicherung Typ</label>
<NcSelect :model-value="member.kvTyp" <NcSelect :model-value="member.kvTyp"
:options="kvTypOptions" :options="kvTypOptions"
:reduce="o => o.value"
label="label"
:disabled="!editing" :disabled="!editing"
:clearable="true" :clearable="true"
@update:model-value="$emit('update', 'kvTyp', $event)" /> @update:model-value="$emit('update', 'kvTyp', $event)" />
@@ -108,7 +125,7 @@
</div> </div>
<div class="member-form__field member-form__field--full"> <div class="member-form__field member-form__field--full">
<label>Zusaetzliche Notizen</label> <label>Zusätzliche Notizen</label>
<textarea :value="member.zusatzNotizen || ''" <textarea :value="member.zusatzNotizen || ''"
:disabled="!editing" :disabled="!editing"
rows="3" rows="3"
@@ -134,12 +151,16 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
errors: {
type: Object,
default: () => ({}),
},
}) })
defineEmits(['update']) defineEmits(['update'])
const geschlechtOptions = [ const geschlechtOptions = [
{ value: 'maennlich', label: 'Maennlich' }, { value: 'maennlich', label: 'Männlich' },
{ value: 'weiblich', label: 'Weiblich' }, { value: 'weiblich', label: 'Weiblich' },
{ value: 'divers', label: 'Divers' }, { value: 'divers', label: 'Divers' },
] ]
@@ -183,6 +204,11 @@ const kvTypOptions = [
margin-top: 16px; margin-top: 16px;
} }
.member-form__error {
color: var(--color-error);
font-size: 0.8em;
}
/* Prevent long option labels (e.g. "Erziehungsberechtigter") from wrapping */ /* Prevent long option labels (e.g. "Erziehungsberechtigter") from wrapping */
.member-form__field :deep(.vs__dropdown-menu) { .member-form__field :deep(.vs__dropdown-menu) {
min-width: 220px; min-width: 220px;
+2 -2
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="phone-input"> <div class="phone-input">
<NcTextField :value="modelValue" <NcTextField :model-value="modelValue"
:disabled="disabled" :disabled="disabled"
:placeholder="'+49 176 12345678'" :placeholder="'+49 176 12345678'"
@update:value="onInput" /> @update:model-value="onInput" />
<div class="phone-input__feedback"> <div class="phone-input__feedback">
<span v-if="validationState === 'valid'" class="phone-input__valid"> <span v-if="validationState === 'valid'" class="phone-input__valid">
Gültig ({{ formattedNumber }}) Gültig ({{ formattedNumber }})
+2 -2
View File
@@ -19,10 +19,10 @@
</template> </template>
<template v-else> <template v-else>
<label>{{ field.label }}{{ field.required ? ' *' : '' }}</label> <label>{{ field.label }}{{ field.required ? ' *' : '' }}</label>
<NcTextField :value="item[field.key] || ''" <NcTextField :model-value="item[field.key] || ''"
:disabled="!editing" :disabled="!editing"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
@update:value="$emit('update', index, field.key, $event)" /> @update:model-value="$emit('update', index, field.key, $event)" />
</template> </template>
</div> </div>
</div> </div>
+34
View File
@@ -9,7 +9,41 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router.js' import router from './router.js'
// @nextcloud/vue v9 expects loadState("core","apps") to be an array,
// but Nextcloud 28 provides it as an object keyed by app ID.
// Patch it before mounting so @nextcloud/vue's .find() call works.
const appsStateEl = document.querySelector('input[id="initial-state-core-apps"]')
if (appsStateEl) {
try {
const decoded = JSON.parse(atob(appsStateEl.value))
if (decoded && !Array.isArray(decoded)) {
appsStateEl.value = btoa(JSON.stringify(Object.values(decoded)))
}
} catch (_) {
// ignore — let @nextcloud/vue handle the fallback
}
}
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
// not via webpack DefinePlugin globals.
app.provide('appName', 'mitgliederverwaltung')
app.provide('appVersion', '0.1.0')
app.mount('#mitgliederverwaltung') app.mount('#mitgliederverwaltung')
// Inject global button style fix after all NcButton styles have loaded.
// NcButton's scoped CSS sets overflow:hidden on .button-vue__text which
// truncates button labels. CSS-in-Vue can't reliably override scoped styles,
// so we inject a <style> tag directly.
const fixStyle = document.createElement('style')
fixStyle.textContent = `
#mitgliederverwaltung .button-vue { overflow: visible; padding: 0 16px; white-space: nowrap; }
#mitgliederverwaltung .button-vue .button-vue__text { overflow: visible !important; }
#mitgliederverwaltung .button-vue--icon-only { padding: 0; }
#mitgliederverwaltung .app-navigation-entry.active { border-radius: var(--border-radius-element, 8px) !important; }
`
document.head.appendChild(fixStyle)
+4 -4
View File
@@ -21,16 +21,16 @@
<div class="audit-log__filter"> <div class="audit-log__filter">
<label>Benutzer</label> <label>Benutzer</label>
<NcTextField :value="store.filters.ncUserId || ''" <NcTextField :model-value="store.filters.ncUserId || ''"
placeholder="Benutzer-ID" placeholder="Benutzer-ID"
@update:value="onFilterChange('ncUserId', $event || null)" /> @update:model-value="onFilterChange('ncUserId', $event || null)" />
</div> </div>
<div class="audit-log__filter"> <div class="audit-log__filter">
<label>Entität-ID</label> <label>Entität-ID</label>
<NcTextField :value="store.filters.entitaetId || ''" <NcTextField :model-value="store.filters.entitaetId || ''"
placeholder="ID" placeholder="ID"
@update:value="onFilterChange('entitaetId', $event || null)" /> @update:model-value="onFilterChange('entitaetId', $event || null)" />
</div> </div>
<div class="audit-log__filter"> <div class="audit-log__filter">
+2 -2
View File
@@ -73,9 +73,9 @@
<!-- Add member --> <!-- Add member -->
<div v-if="editing" class="family-detail__add-member"> <div v-if="editing" class="family-detail__add-member">
<NcTextField :value.sync="memberSearchQuery" <NcTextField :model-value="memberSearchQuery"
placeholder="Mitglied suchen (Name eingeben)..." placeholder="Mitglied suchen (Name eingeben)..."
@update:value="onMemberSearch" /> @update:model-value="onMemberSearch" />
<div v-if="memberSearchResults.length > 0" class="family-detail__search-results"> <div v-if="memberSearchResults.length > 0" class="family-detail__search-results">
<div v-for="result in memberSearchResults" <div v-for="result in memberSearchResults"
:key="result.id" :key="result.id"
+2 -2
View File
@@ -3,12 +3,12 @@
<div class="family-list__header"> <div class="family-list__header">
<h2>Familien</h2> <h2>Familien</h2>
<div class="family-list__actions"> <div class="family-list__actions">
<NcTextField :value.sync="searchQuery" <NcTextField :model-value="searchQuery"
:placeholder="'Familien suchen...'" :placeholder="'Familien suchen...'"
:show-trailing-button="searchQuery !== ''" :show-trailing-button="searchQuery !== ''"
trailing-button-icon="close" trailing-button-icon="close"
@trailing-button-click="clearSearch" @trailing-button-click="clearSearch"
@update:value="onSearch" /> @update:model-value="onSearch" />
<NcButton type="primary" <NcButton type="primary"
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })"> @click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
<template #icon> <template #icon>
+4
View File
@@ -234,11 +234,15 @@ const editingNotes = ref(null)
const editNotesText = ref('') const editNotesText = ref('')
onMounted(async () => { onMounted(async () => {
// Fetch all members for the name lookup (not just first page)
const savedLimit = membersStore.limit
membersStore.limit = 9999
await Promise.all([ await Promise.all([
feesStore.fetchRules(), feesStore.fetchRules(),
feesStore.fetchRecords(), feesStore.fetchRecords(),
membersStore.fetchMembers(0), membersStore.fetchMembers(0),
]) ])
membersStore.limit = savedLimit
}) })
// ── Year change ───────────────────────────────────────────────────── // ── Year change ─────────────────────────────────────────────────────
+22 -2
View File
@@ -56,6 +56,7 @@
<MemberForm :member="formData" <MemberForm :member="formData"
:is-new="isNew" :is-new="isNew"
:editing="editing" :editing="editing"
:errors="validationErrors"
@update="onFormUpdate" /> @update="onFormUpdate" />
<!-- Sub-entities: Addresses --> <!-- Sub-entities: Addresses -->
@@ -108,7 +109,7 @@
</template> </template>
<template v-else> <template v-else>
<NcButton type="primary" <NcButton type="primary"
:disabled="store.loading" :disabled="store.loading || !isFormValid"
@click="save"> @click="save">
Speichern Speichern
</NcButton> </NcButton>
@@ -160,6 +161,19 @@ const formData = ref(createEmptyMember())
const isNew = computed(() => props.id === 'new') const isNew = computed(() => props.id === 'new')
const validationErrors = computed(() => {
const errors = {}
const d = formData.value
if (!d.vorname?.trim()) errors.vorname = 'Vorname ist erforderlich'
if (!d.nachname?.trim()) errors.nachname = 'Nachname ist erforderlich'
if (!d.geburtsdatum) errors.geburtsdatum = 'Geburtsdatum ist erforderlich'
if (!d.eintritt) errors.eintritt = 'Eintrittsdatum ist erforderlich'
if (!d.rolle) errors.rolle = 'Rolle ist erforderlich'
return errors
})
const isFormValid = computed(() => Object.keys(validationErrors.value).length === 0)
const tabs = [ const tabs = [
{ id: 'personal', label: 'Persönlich' }, { id: 'personal', label: 'Persönlich' },
{ id: 'family', label: 'Familie' }, { id: 'family', label: 'Familie' },
@@ -215,6 +229,7 @@ watch(() => props.id, async () => {
}) })
async function loadMember() { async function loadMember() {
store.clearError()
try { try {
const data = await store.fetchMember(Number(props.id)) const data = await store.fetchMember(Number(props.id))
member.value = data member.value = data
@@ -224,6 +239,10 @@ async function loadMember() {
} }
} }
function todayISO() {
return new Date().toISOString().split('T')[0]
}
function createEmptyMember() { function createEmptyMember() {
return { return {
vorname: '', vorname: '',
@@ -232,7 +251,7 @@ function createEmptyMember() {
geschlecht: null, geschlecht: null,
rolle: 'mitglied', rolle: 'mitglied',
stufeId: null, stufeId: null,
eintritt: '', eintritt: todayISO(),
austritt: null, austritt: null,
status: 'aktiv', status: 'aktiv',
allergienEncrypted: null, allergienEncrypted: null,
@@ -386,6 +405,7 @@ function formatStatus(status) {
margin-bottom: 20px; margin-bottom: 20px;
} }
.member-detail__header h2 { .member-detail__header h2 {
margin: 0; margin: 0;
} }
+11 -30
View File
@@ -3,12 +3,6 @@
<div class="member-list__header"> <div class="member-list__header">
<h2>Mitglieder</h2> <h2>Mitglieder</h2>
<div class="member-list__actions"> <div class="member-list__actions">
<NcTextField :model-value="searchQuery"
:placeholder="'Mitglieder suchen...'"
:show-trailing-button="searchQuery !== ''"
trailing-button-icon="close"
@trailing-button-click="clearSearch"
@update:model-value="onSearch" />
<NcButton type="primary" <NcButton type="primary"
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })"> @click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
<template #icon> <template #icon>
@@ -53,13 +47,12 @@
<!-- Empty state --> <!-- Empty state -->
<NcEmptyContent v-else-if="!store.loading && store.members.length === 0" <NcEmptyContent v-else-if="!store.loading && store.members.length === 0"
name="Keine Mitglieder" name="Keine Mitglieder"
:description="searchQuery ? 'Keine Mitglieder für diese Suche gefunden.' : 'Noch keine Mitglieder angelegt.'"> description="Noch keine Mitglieder angelegt.">
<template #icon> <template #icon>
<AccountGroup :size="64" /> <AccountGroup :size="64" />
</template> </template>
<template #action> <template #action>
<NcButton v-if="!searchQuery" <NcButton type="primary"
type="primary"
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })"> @click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
Erstes Mitglied anlegen Erstes Mitglied anlegen
</NcButton> </NcButton>
@@ -87,9 +80,12 @@
</th> </th>
<th class="member-list__th member-list__th--sortable" <th class="member-list__th member-list__th--sortable"
@click="toggleSort('geburtsdatum')"> @click="toggleSort('geburtsdatum')">
Alter Geburtsdatum
<SortIcon :field="'geburtsdatum'" :current-sort="sortField" :sort-asc="sortAsc" /> <SortIcon :field="'geburtsdatum'" :current-sort="sortField" :sort-asc="sortAsc" />
</th> </th>
<th class="member-list__th">
Alter
</th>
<th class="member-list__th"> <th class="member-list__th">
Rolle Rolle
</th> </th>
@@ -111,6 +107,9 @@
{{ formatStatus(member.status) }} {{ formatStatus(member.status) }}
</span> </span>
</td> </td>
<td class="member-list__td">
{{ member.geburtsdatum || '—' }}
</td>
<td class="member-list__td"> <td class="member-list__td">
{{ calculateAge(member.geburtsdatum) }} {{ calculateAge(member.geburtsdatum) }}
</td> </td>
@@ -141,7 +140,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useMembersStore } from '../stores/members.js' import { useMembersStore } from '../stores/members.js'
import { useStufenStore } from '../stores/stufen.js' import { useStufenStore } from '../stores/stufen.js'
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from 'vue-material-design-icons/Plus.vue'
@@ -152,10 +151,8 @@ import SortIcon from '../components/SortIcon.vue'
const store = useMembersStore() const store = useMembersStore()
const stufenStore = useStufenStore() const stufenStore = useStufenStore()
const searchQuery = ref('')
const sortField = ref('nachname') const sortField = ref('nachname')
const sortAsc = ref(true) const sortAsc = ref(true)
let searchTimeout = null
/** /**
* Preset filter definitions (Issue #34). * Preset filter definitions (Issue #34).
@@ -221,23 +218,6 @@ function toggleSort(field) {
} }
} }
function onSearch(value) {
searchQuery.value = value
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
if (value.trim()) {
store.searchMembers(value.trim())
} else {
store.fetchMembers()
}
}, 300)
}
function clearSearch() {
searchQuery.value = ''
store.fetchMembers()
}
function reload() { function reload() {
store.clearError() store.clearError()
store.fetchMembers() store.fetchMembers()
@@ -315,6 +295,7 @@ function formatRolle(rolle) {
align-items: center; align-items: center;
} }
.member-list__loading { .member-list__loading {
display: flex; display: flex;
justify-content: center; justify-content: center;
+21 -13
View File
@@ -23,14 +23,15 @@
<div v-for="report in reportsStore.reportTypes" <div v-for="report in reportsStore.reportTypes"
:key="report.id" :key="report.id"
class="reports__type-card" class="reports__type-card"
:class="{ 'reports__type-card--selected': reportsStore.selectedType === report.id }" :class="{
'reports__type-card--selected': reportsStore.selectedType === report.id,
'reports__type-card--sensitive': report.sensitive,
}"
:title="report.sensitive ? 'Dieser Bericht enthält sensible personenbezogene Daten.' : undefined"
@click="reportsStore.selectType(report.id)"> @click="reportsStore.selectType(report.id)">
<div class="reports__type-name"> <div class="reports__type-name">
{{ report.name }} {{ report.name }}
</div> </div>
<div v-if="report.sensitive" class="reports__type-badge">
Sensibel
</div>
</div> </div>
</div> </div>
@@ -353,15 +354,22 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
} }
.reports__type-badge { .reports__type-card--sensitive {
position: absolute; border-color: var(--color-error, #c00);
top: 4px; }
right: 4px;
font-size: 10px; .reports__type-card--sensitive .reports__type-name {
background: var(--color-warning, #e68a00); color: var(--color-error, #c00);
color: white; }
padding: 2px 6px;
border-radius: 4px; .reports__type-card--sensitive:hover {
border-color: var(--color-error, #c00);
background-color: color-mix(in srgb, var(--color-error, #c00) 8%, transparent);
}
.reports__type-card--sensitive.reports__type-card--selected {
border-color: var(--color-error, #c00);
background-color: color-mix(in srgb, var(--color-error, #c00) 12%, transparent);
} }
/* ── Filters ───────────────────────────────────────────────────── */ /* ── Filters ───────────────────────────────────────────────────── */
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
new VueLoaderPlugin(), new VueLoaderPlugin(),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
appName: JSON.stringify('mitgliederverwaltung'), appName: JSON.stringify('mitgliederverwaltung'),
appVersion: JSON.stringify('0.0.3'), appVersion: JSON.stringify('0.1.0'),
}), }),
new webpack.optimize.LimitChunkCountPlugin({ new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1, maxChunks: 1,