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
|
# Docker
|
||||||
.build_done
|
.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>
|
<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>
|
||||||
|
|||||||
@@ -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">
|
<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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }})
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user