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:
@@ -27,3 +27,8 @@ package-lock.json
|
||||
|
||||
# Docker
|
||||
.build_done
|
||||
|
||||
# Test artifacts
|
||||
.playwright-mcp/
|
||||
screenshots/
|
||||
test-results/
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@
|
||||
<name>Mitgliederverwaltung</name>
|
||||
<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>
|
||||
<version>0.0.3</version>
|
||||
<version>0.1.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>shahondin1624</author>
|
||||
<namespace>Mitgliederverwaltung</namespace>
|
||||
|
||||
@@ -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
@@ -1,12 +1,12 @@
|
||||
<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) -->
|
||||
<!-- Center person (front) -->
|
||||
<circle cx="16" cy="10" r="4" fill="#ffffff"/>
|
||||
<path d="M10 24c0-3.3 2.7-6 6-6s6 2.7 6 6v2H10v-2z" fill="#ffffff"/>
|
||||
<!-- Left person (back) -->
|
||||
<circle cx="8" cy="12" r="3" fill="#aaaaaa"/>
|
||||
<path d="M3.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#aaaaaa"/>
|
||||
<!-- Right person (back) -->
|
||||
<circle cx="24" cy="12" r="3" fill="#aaaaaa"/>
|
||||
<path d="M19.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#aaaaaa"/>
|
||||
<!-- Group/members icon (dark theme variant) -->
|
||||
<!-- Left person -->
|
||||
<circle cx="7" cy="11" r="3" fill="#eee"/>
|
||||
<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"/>
|
||||
<!-- Center person (larger, in front) -->
|
||||
<circle cx="16" cy="8" r="3.8" fill="#eee"/>
|
||||
<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 -->
|
||||
<circle cx="25" cy="11" r="3" fill="#eee"/>
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 635 B After Width: | Height: | Size: 708 B |
+10
-10
@@ -1,12 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<!-- Member management icon: three people silhouettes -->
|
||||
<!-- Center person (front) -->
|
||||
<circle cx="16" cy="10" r="4" fill="#1a1a1a"/>
|
||||
<path d="M10 24c0-3.3 2.7-6 6-6s6 2.7 6 6v2H10v-2z" fill="#1a1a1a"/>
|
||||
<!-- Left person (back) -->
|
||||
<circle cx="8" cy="12" r="3" fill="#666"/>
|
||||
<path d="M3.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#666"/>
|
||||
<!-- Right person (back) -->
|
||||
<circle cx="24" cy="12" r="3" fill="#666"/>
|
||||
<path d="M19.5 23c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5v1h-9v-1z" fill="#666"/>
|
||||
<!-- Group/members icon inspired by flaticon.com/de/kostenloses-icon/gruppe_681443 -->
|
||||
<!-- Left person -->
|
||||
<circle cx="7" cy="11" r="3" fill="#222"/>
|
||||
<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"/>
|
||||
<!-- Center person (larger, in front) -->
|
||||
<circle cx="16" cy="8" r="3.8" fill="#222"/>
|
||||
<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 -->
|
||||
<circle cx="25" cy="11" r="3" fill="#222"/>
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 746 B |
@@ -124,9 +124,9 @@ class Version000004Date20260407000003 extends SimpleMigrationStep {
|
||||
}
|
||||
|
||||
$defaults = [
|
||||
['name' => 'Woelflinge', 'sort_order' => 1, 'age_range_min' => 7, 'age_range_max' => 10, 'color' => '#FFA500'],
|
||||
['name' => 'Pfadfinder', 'sort_order' => 2, 'age_range_min' => 11, 'age_range_max' => 15, 'color' => '#2196F3'],
|
||||
['name' => 'Rover', 'sort_order' => 3, 'age_range_min' => 16, 'age_range_max' => 25, 'color' => '#4CAF50'],
|
||||
['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' => '#0c800c'],
|
||||
['name' => 'Rover', 'sort_order' => 3, 'age_range_min' => 16, 'age_range_max' => 25, 'color' => '#dd1212'],
|
||||
];
|
||||
|
||||
foreach ($defaults as $stufe) {
|
||||
|
||||
@@ -42,25 +42,25 @@ class Version000014Date20260409000000 extends SimpleMigrationStep {
|
||||
|
||||
$defaultStufen = [
|
||||
[
|
||||
'name' => 'Woelflinge',
|
||||
'name' => 'Wölflinge',
|
||||
'sort_order' => 0,
|
||||
'age_range_min' => 7,
|
||||
'age_range_max' => 10,
|
||||
'color' => '#FF8C00',
|
||||
'age_range_min' => 6,
|
||||
'age_range_max' => 11,
|
||||
'color' => '#FFD700',
|
||||
],
|
||||
[
|
||||
'name' => 'Pfadfinder',
|
||||
'sort_order' => 1,
|
||||
'age_range_min' => 10,
|
||||
'age_range_max' => 16,
|
||||
'age_range_min' => 12,
|
||||
'age_range_max' => 17,
|
||||
'color' => '#228B22',
|
||||
],
|
||||
[
|
||||
'name' => 'Rover',
|
||||
'sort_order' => 2,
|
||||
'age_range_min' => 16,
|
||||
'age_range_min' => 18,
|
||||
'age_range_max' => null,
|
||||
'color' => '#4169E1',
|
||||
'color' => '#DD1212',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -70,8 +70,8 @@ class Version000014Date20260409000000 extends SimpleMigrationStep {
|
||||
->values([
|
||||
'name' => $qb->createNamedParameter($stufe['name']),
|
||||
'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_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_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_STR),
|
||||
'color' => $qb->createNamedParameter($stufe['color']),
|
||||
]);
|
||||
$qb->executeStatement();
|
||||
|
||||
+105
-12
@@ -2,10 +2,30 @@
|
||||
<div class="family-form">
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Familienname *</label>
|
||||
<NcTextField :value="family.name"
|
||||
<NcTextField :model-value="family.name"
|
||||
:disabled="!editing"
|
||||
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>
|
||||
|
||||
<!-- Banking section (only visible with banking permission) -->
|
||||
@@ -14,19 +34,19 @@
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Kontoinhaber</label>
|
||||
<NcTextField :value="family.kontoinhaberEncrypted || ''"
|
||||
<NcTextField :model-value="family.kontoinhaberEncrypted || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="Name des Kontoinhabers"
|
||||
@update:value="$emit('update', 'kontoinhaberEncrypted', $event)" />
|
||||
@update:model-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 || ''"
|
||||
<NcTextField :model-value="family.ibanEncrypted || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="DE89 3704 0044 0532 0130 00"
|
||||
@update:value="onIbanInput" />
|
||||
@update:model-value="onIbanInput" />
|
||||
<span v-if="ibanValidation !== null" :class="ibanValidationClass">
|
||||
{{ ibanValidation }}
|
||||
</span>
|
||||
@@ -35,26 +55,28 @@
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">BIC</label>
|
||||
<NcTextField :value="family.bic || ''"
|
||||
<NcTextField :model-value="family.bic || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="COBADEFFXXX"
|
||||
@update:value="$emit('update', 'bic', $event)" />
|
||||
@update:model-value="$emit('update', 'bic', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="family-form__row">
|
||||
<label class="family-form__label">Kreditinstitut</label>
|
||||
<NcTextField :value="family.kreditinstitut || ''"
|
||||
<NcTextField :model-value="family.kreditinstitut || ''"
|
||||
:disabled="!editing"
|
||||
placeholder="Name der Bank"
|
||||
@update:value="$emit('update', 'kreditinstitut', $event)" />
|
||||
@update:model-value="$emit('update', 'kreditinstitut', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { NcTextField } from '@nextcloud/vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { NcTextField, NcSelect } from '@nextcloud/vue'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
|
||||
const props = defineProps({
|
||||
family: {
|
||||
@@ -70,6 +92,58 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
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(() => {
|
||||
if (ibanValidation.value === null) return ''
|
||||
@@ -181,4 +255,23 @@ watch(() => props.family.ibanEncrypted, (val) => {
|
||||
color: var(--color-error);
|
||||
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>
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
<NcTextField :model-value="member.vorname"
|
||||
:disabled="!editing"
|
||||
:required="true"
|
||||
:error="!!errors.vorname"
|
||||
@update:model-value="$emit('update', 'vorname', $event)" />
|
||||
<span v-if="editing && errors.vorname" class="member-form__error">{{ errors.vorname }}</span>
|
||||
</div>
|
||||
<div class="member-form__field">
|
||||
<label>Nachname *</label>
|
||||
<NcTextField :model-value="member.nachname"
|
||||
:disabled="!editing"
|
||||
:required="true"
|
||||
:error="!!errors.nachname"
|
||||
@update:model-value="$emit('update', 'nachname', $event)" />
|
||||
<span v-if="editing && errors.nachname" class="member-form__error">{{ errors.nachname }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Dates -->
|
||||
@@ -24,12 +28,16 @@
|
||||
type="date"
|
||||
:disabled="!editing"
|
||||
:required="true"
|
||||
:error="!!errors.geburtsdatum"
|
||||
@update:model-value="$emit('update', 'geburtsdatum', $event)" />
|
||||
<span v-if="editing && errors.geburtsdatum" class="member-form__error">{{ errors.geburtsdatum }}</span>
|
||||
</div>
|
||||
<div class="member-form__field">
|
||||
<label>Geschlecht</label>
|
||||
<NcSelect :model-value="member.geschlecht"
|
||||
:options="geschlechtOptions"
|
||||
:reduce="o => o.value"
|
||||
label="label"
|
||||
:disabled="!editing"
|
||||
:clearable="true"
|
||||
@update:model-value="$emit('update', 'geschlecht', $event)" />
|
||||
@@ -40,13 +48,18 @@
|
||||
<label>Rolle *</label>
|
||||
<NcSelect :model-value="member.rolle"
|
||||
:options="rolleOptions"
|
||||
:reduce="o => o.value"
|
||||
label="label"
|
||||
:disabled="!editing"
|
||||
@update:model-value="$emit('update', 'rolle', $event)" />
|
||||
<span v-if="editing && errors.rolle" class="member-form__error">{{ errors.rolle }}</span>
|
||||
</div>
|
||||
<div class="member-form__field">
|
||||
<label>Status</label>
|
||||
<NcSelect :model-value="member.status"
|
||||
:options="statusOptions"
|
||||
:reduce="o => o.value"
|
||||
label="label"
|
||||
:disabled="!editing || isNew"
|
||||
@update:model-value="$emit('update', 'status', $event)" />
|
||||
</div>
|
||||
@@ -58,7 +71,9 @@
|
||||
type="date"
|
||||
:disabled="!editing"
|
||||
:required="true"
|
||||
:error="!!errors.eintritt"
|
||||
@update:model-value="$emit('update', 'eintritt', $event)" />
|
||||
<span v-if="editing && errors.eintritt" class="member-form__error">{{ errors.eintritt }}</span>
|
||||
</div>
|
||||
<div class="member-form__field">
|
||||
<label>Austrittsdatum</label>
|
||||
@@ -74,6 +89,8 @@
|
||||
<label>Krankenversicherung Typ</label>
|
||||
<NcSelect :model-value="member.kvTyp"
|
||||
:options="kvTypOptions"
|
||||
:reduce="o => o.value"
|
||||
label="label"
|
||||
:disabled="!editing"
|
||||
:clearable="true"
|
||||
@update:model-value="$emit('update', 'kvTyp', $event)" />
|
||||
@@ -108,7 +125,7 @@
|
||||
</div>
|
||||
|
||||
<div class="member-form__field member-form__field--full">
|
||||
<label>Zusaetzliche Notizen</label>
|
||||
<label>Zusätzliche Notizen</label>
|
||||
<textarea :value="member.zusatzNotizen || ''"
|
||||
:disabled="!editing"
|
||||
rows="3"
|
||||
@@ -134,12 +151,16 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['update'])
|
||||
|
||||
const geschlechtOptions = [
|
||||
{ value: 'maennlich', label: 'Maennlich' },
|
||||
{ value: 'maennlich', label: 'Männlich' },
|
||||
{ value: 'weiblich', label: 'Weiblich' },
|
||||
{ value: 'divers', label: 'Divers' },
|
||||
]
|
||||
@@ -183,6 +204,11 @@ const kvTypOptions = [
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.member-form__error {
|
||||
color: var(--color-error);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Prevent long option labels (e.g. "Erziehungsberechtigter") from wrapping */
|
||||
.member-form__field :deep(.vs__dropdown-menu) {
|
||||
min-width: 220px;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="phone-input">
|
||||
<NcTextField :value="modelValue"
|
||||
<NcTextField :model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="'+49 176 12345678'"
|
||||
@update:value="onInput" />
|
||||
@update:model-value="onInput" />
|
||||
<div class="phone-input__feedback">
|
||||
<span v-if="validationState === 'valid'" class="phone-input__valid">
|
||||
Gültig ({{ formattedNumber }})
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<label>{{ field.label }}{{ field.required ? ' *' : '' }}</label>
|
||||
<NcTextField :value="item[field.key] || ''"
|
||||
<NcTextField :model-value="item[field.key] || ''"
|
||||
:disabled="!editing"
|
||||
:placeholder="field.placeholder || ''"
|
||||
@update:value="$emit('update', index, field.key, $event)" />
|
||||
@update:model-value="$emit('update', index, field.key, $event)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+34
@@ -9,7 +9,41 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
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)
|
||||
app.use(createPinia())
|
||||
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')
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -21,16 +21,16 @@
|
||||
|
||||
<div class="audit-log__filter">
|
||||
<label>Benutzer</label>
|
||||
<NcTextField :value="store.filters.ncUserId || ''"
|
||||
<NcTextField :model-value="store.filters.ncUserId || ''"
|
||||
placeholder="Benutzer-ID"
|
||||
@update:value="onFilterChange('ncUserId', $event || null)" />
|
||||
@update:model-value="onFilterChange('ncUserId', $event || null)" />
|
||||
</div>
|
||||
|
||||
<div class="audit-log__filter">
|
||||
<label>Entität-ID</label>
|
||||
<NcTextField :value="store.filters.entitaetId || ''"
|
||||
<NcTextField :model-value="store.filters.entitaetId || ''"
|
||||
placeholder="ID"
|
||||
@update:value="onFilterChange('entitaetId', $event || null)" />
|
||||
@update:model-value="onFilterChange('entitaetId', $event || null)" />
|
||||
</div>
|
||||
|
||||
<div class="audit-log__filter">
|
||||
|
||||
@@ -73,9 +73,9 @@
|
||||
|
||||
<!-- Add member -->
|
||||
<div v-if="editing" class="family-detail__add-member">
|
||||
<NcTextField :value.sync="memberSearchQuery"
|
||||
<NcTextField :model-value="memberSearchQuery"
|
||||
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-for="result in memberSearchResults"
|
||||
:key="result.id"
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="family-list__header">
|
||||
<h2>Familien</h2>
|
||||
<div class="family-list__actions">
|
||||
<NcTextField :value.sync="searchQuery"
|
||||
<NcTextField :model-value="searchQuery"
|
||||
:placeholder="'Familien suchen...'"
|
||||
:show-trailing-button="searchQuery !== ''"
|
||||
trailing-button-icon="close"
|
||||
@trailing-button-click="clearSearch"
|
||||
@update:value="onSearch" />
|
||||
@update:model-value="onSearch" />
|
||||
<NcButton type="primary"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
||||
<template #icon>
|
||||
|
||||
@@ -234,11 +234,15 @@ const editingNotes = ref(null)
|
||||
const editNotesText = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch all members for the name lookup (not just first page)
|
||||
const savedLimit = membersStore.limit
|
||||
membersStore.limit = 9999
|
||||
await Promise.all([
|
||||
feesStore.fetchRules(),
|
||||
feesStore.fetchRecords(),
|
||||
membersStore.fetchMembers(0),
|
||||
])
|
||||
membersStore.limit = savedLimit
|
||||
})
|
||||
|
||||
// ── Year change ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<MemberForm :member="formData"
|
||||
:is-new="isNew"
|
||||
:editing="editing"
|
||||
:errors="validationErrors"
|
||||
@update="onFormUpdate" />
|
||||
|
||||
<!-- Sub-entities: Addresses -->
|
||||
@@ -108,7 +109,7 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<NcButton type="primary"
|
||||
:disabled="store.loading"
|
||||
:disabled="store.loading || !isFormValid"
|
||||
@click="save">
|
||||
Speichern
|
||||
</NcButton>
|
||||
@@ -160,6 +161,19 @@ const formData = ref(createEmptyMember())
|
||||
|
||||
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 = [
|
||||
{ id: 'personal', label: 'Persönlich' },
|
||||
{ id: 'family', label: 'Familie' },
|
||||
@@ -215,6 +229,7 @@ watch(() => props.id, async () => {
|
||||
})
|
||||
|
||||
async function loadMember() {
|
||||
store.clearError()
|
||||
try {
|
||||
const data = await store.fetchMember(Number(props.id))
|
||||
member.value = data
|
||||
@@ -224,6 +239,10 @@ async function loadMember() {
|
||||
}
|
||||
}
|
||||
|
||||
function todayISO() {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function createEmptyMember() {
|
||||
return {
|
||||
vorname: '',
|
||||
@@ -232,7 +251,7 @@ function createEmptyMember() {
|
||||
geschlecht: null,
|
||||
rolle: 'mitglied',
|
||||
stufeId: null,
|
||||
eintritt: '',
|
||||
eintritt: todayISO(),
|
||||
austritt: null,
|
||||
status: 'aktiv',
|
||||
allergienEncrypted: null,
|
||||
@@ -386,6 +405,7 @@ function formatStatus(status) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.member-detail__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+11
-30
@@ -3,12 +3,6 @@
|
||||
<div class="member-list__header">
|
||||
<h2>Mitglieder</h2>
|
||||
<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"
|
||||
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
||||
<template #icon>
|
||||
@@ -53,13 +47,12 @@
|
||||
<!-- Empty state -->
|
||||
<NcEmptyContent v-else-if="!store.loading && store.members.length === 0"
|
||||
name="Keine Mitglieder"
|
||||
:description="searchQuery ? 'Keine Mitglieder für diese Suche gefunden.' : 'Noch keine Mitglieder angelegt.'">
|
||||
description="Noch keine Mitglieder angelegt.">
|
||||
<template #icon>
|
||||
<AccountGroup :size="64" />
|
||||
</template>
|
||||
<template #action>
|
||||
<NcButton v-if="!searchQuery"
|
||||
type="primary"
|
||||
<NcButton type="primary"
|
||||
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
||||
Erstes Mitglied anlegen
|
||||
</NcButton>
|
||||
@@ -87,9 +80,12 @@
|
||||
</th>
|
||||
<th class="member-list__th member-list__th--sortable"
|
||||
@click="toggleSort('geburtsdatum')">
|
||||
Alter
|
||||
Geburtsdatum
|
||||
<SortIcon :field="'geburtsdatum'" :current-sort="sortField" :sort-asc="sortAsc" />
|
||||
</th>
|
||||
<th class="member-list__th">
|
||||
Alter
|
||||
</th>
|
||||
<th class="member-list__th">
|
||||
Rolle
|
||||
</th>
|
||||
@@ -111,6 +107,9 @@
|
||||
{{ formatStatus(member.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="member-list__td">
|
||||
{{ member.geburtsdatum || '—' }}
|
||||
</td>
|
||||
<td class="member-list__td">
|
||||
{{ calculateAge(member.geburtsdatum) }}
|
||||
</td>
|
||||
@@ -141,7 +140,7 @@
|
||||
|
||||
<script setup>
|
||||
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 { useStufenStore } from '../stores/stufen.js'
|
||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||
@@ -152,10 +151,8 @@ import SortIcon from '../components/SortIcon.vue'
|
||||
const store = useMembersStore()
|
||||
const stufenStore = useStufenStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const sortField = ref('nachname')
|
||||
const sortAsc = ref(true)
|
||||
let searchTimeout = null
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
store.clearError()
|
||||
store.fetchMembers()
|
||||
@@ -315,6 +295,7 @@ function formatRolle(rolle) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.member-list__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+21
-13
@@ -23,14 +23,15 @@
|
||||
<div v-for="report in reportsStore.reportTypes"
|
||||
:key="report.id"
|
||||
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)">
|
||||
<div class="reports__type-name">
|
||||
{{ report.name }}
|
||||
</div>
|
||||
<div v-if="report.sensitive" class="reports__type-badge">
|
||||
Sensibel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,15 +354,22 @@ onMounted(() => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.reports__type-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
font-size: 10px;
|
||||
background: var(--color-warning, #e68a00);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
.reports__type-card--sensitive {
|
||||
border-color: var(--color-error, #c00);
|
||||
}
|
||||
|
||||
.reports__type-card--sensitive .reports__type-name {
|
||||
color: var(--color-error, #c00);
|
||||
}
|
||||
|
||||
.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 ───────────────────────────────────────────────────── */
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ module.exports = {
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
appName: JSON.stringify('mitgliederverwaltung'),
|
||||
appVersion: JSON.stringify('0.0.3'),
|
||||
appVersion: JSON.stringify('0.1.0'),
|
||||
}),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
|
||||
Reference in New Issue
Block a user