diff --git a/Justfile b/Justfile index b835cf4..c68c473 100644 --- a/Justfile +++ b/Justfile @@ -32,6 +32,8 @@ lint: mix format --check-formatted mix compile --warnings-as-errors mix credo + # Check that all German translations are filled (UI must be in German) + @bash -c 'for file in priv/gettext/de/LC_MESSAGES/*.po; do awk "/^msgid \"\"$/{header=1; next} /^msgid /{header=0} /^msgstr \"\"$/ && !header{print FILENAME\":\"NR\": \" \$0; exit 1}" "$file" || exit 1; done' mix gettext.extract --check-up-to-date audit: @@ -116,4 +118,4 @@ init-prod-secrets: # Start production environment with Docker Compose start-prod: init-prod-secrets - docker compose -f docker-compose.prod.yml up -d \ No newline at end of file + docker compose -f docker-compose.prod.yml up -d diff --git a/assets/css/app.css b/assets/css/app.css index ea63a2d..97961ab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,4 +99,213 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* ============================================ + Sidebar Base Styles + ============================================ */ + +/* Desktop Sidebar Base */ +.sidebar { + @apply flex flex-col bg-base-200 min-h-screen; + @apply transition-[width] duration-300 ease-in-out; + @apply relative; + width: 16rem; /* Expanded: w-64 */ + z-index: 40; +} + +/* Collapsed State */ +[data-sidebar-expanded="false"] .sidebar { + width: 4rem; /* Collapsed: w-16 */ +} + +/* ============================================ + Header - Logo Centering + ============================================ */ + +/* Header container with smooth transition for gap */ +.sidebar > div:first-child { + @apply transition-all duration-300; +} + +/* ============================================ + Text Labels - Hide in Collapsed State + ============================================ */ + +.menu-label { + @apply transition-all duration-200 whitespace-nowrap; + transition-delay: 0ms; /* Expanded: sofort sichtbar */ +} + +[data-sidebar-expanded="false"] .sidebar .menu-label { + @apply opacity-0 w-0 overflow-hidden pointer-events-none; + transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */ +} + +/* ============================================ + Toggle Button Icon Swap + ============================================ */ + +.sidebar-collapsed-icon { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon { + @apply block; +} + +/* ============================================ + Menu Groups - Show/Hide Based on State + ============================================ */ + +.expanded-menu-group { + @apply block; +} + +.collapsed-menu-group { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .expanded-menu-group { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group { + @apply block; +} + +/* Collapsed menu group button: center icon under logo */ +.sidebar .collapsed-menu-group button { + padding-left: 14px; +} + +/* ============================================ + Elements Only Visible in Expanded State + ============================================ */ + +.expanded-only { + @apply block transition-opacity duration-200; +} + +[data-sidebar-expanded="false"] .sidebar .expanded-only { + @apply hidden; +} + +/* ============================================ + Tooltip - Only Show in Collapsed State + ============================================ */ + +.sidebar .tooltip::before, +.sidebar .tooltip::after { + @apply opacity-0 pointer-events-none; +} + +[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before, +[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after { + @apply opacity-100; +} + +/* ============================================ + Menu Item Alignment - Icons Centered Under Logo + ============================================ */ + +/* Base alignment: Icons centered under logo (32px from left edge) + - Logo center: 16px padding + 16px (half of 32px) = 32px + - Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px + - Menu has p-2 (8px), so links need 14px additional padding-left */ + +.sidebar .menu > li > a, +.sidebar .menu > li > button { + @apply transition-all duration-300; + padding-left: 14px; +} + +/* Collapsed state: same padding to keep icons at same position + - Remove gap so label (which is opacity-0 w-0) doesn't create space + - Keep padding-left at 14px so icons stay centered under logo */ +[data-sidebar-expanded="false"] .sidebar .menu > li > a, +[data-sidebar-expanded="false"] .sidebar .menu > li > button { + @apply gap-0; + padding-left: 14px; + padding-right: 14px; /* Center icon horizontally in 64px sidebar */ +} + +/* ============================================ + Footer Button Alignment - Left Aligned in Collapsed State + ============================================ */ + +[data-sidebar-expanded="false"] .sidebar .dropdown > button { + @apply px-0; + /* Buttons stay at left position, only label disappears */ +} + +/* ============================================ + User Menu Button - Focus Ring on Avatar + ============================================ */ + +/* Focus ring appears on the avatar when button is focused */ +.user-menu-button:focus .avatar > div { + @apply ring-2 ring-primary ring-offset-2 ring-offset-base-200; +} + +/* ============================================ + User Menu Button - Smooth Centering Transition + ============================================ */ + +/* User menu button transitions smoothly to center */ +.user-menu-button { + @apply transition-all duration-300; +} + +/* In collapsed state, center avatar under logo + - Avatar is 32px (w-8), center it in 64px sidebar + - (64px - 32px) / 2 = 16px padding → avatar center at 32px (same as logo center) */ +[data-sidebar-expanded="false"] .sidebar .user-menu-button { + @apply gap-0; + padding-left: 16px; + padding-right: 16px; + justify-content: center; +} + +/* ============================================ + User Menu Button - Hover Ring on Avatar + ============================================ */ + +/* Smooth transition for avatar ring effects */ +.user-menu-button .avatar > div { + @apply transition-all duration-200; +} + +/* Hover ring appears on the avatar when button is hovered */ +.user-menu-button:hover .avatar > div { + @apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200; +} + +/* ============================================ + Mobile Drawer Width + ============================================ */ + +/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */ +@media (max-width: 1023px) { + .drawer-side .sidebar { + width: 16rem; /* w-64 auch auf Mobile */ + } +} + +/* ============================================ + Drawer Side Overflow Fix für Desktop + ============================================ */ + +/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen + damit Dropdowns und Tooltips über Main Content erscheinen können */ +@media (min-width: 1024px) { + .drawer.lg\:drawer-open .drawer-side { + overflow: visible !important; + overflow-x: visible !important; + overflow-y: visible !important; + } +} + /* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..267ae05 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -73,6 +73,43 @@ Hooks.ComboBox = { } } +// SidebarState hook: Manages sidebar expanded/collapsed state +Hooks.SidebarState = { + mounted() { + // Restore state from localStorage + const expanded = localStorage.getItem('sidebar-expanded') !== 'false' + this.setSidebarState(expanded) + + // Expose toggle function globally + window.toggleSidebar = () => { + const current = this.el.dataset.sidebarExpanded === 'true' + this.setSidebarState(!current) + } + }, + + setSidebarState(expanded) { + // Convert boolean to string for consistency + const expandedStr = expanded ? 'true' : 'false' + + // Update data-attribute (CSS reacts to this) + this.el.dataset.sidebarExpanded = expandedStr + + // Persist to localStorage + localStorage.setItem('sidebar-expanded', expandedStr) + + // Update ARIA for accessibility + const toggleBtn = document.getElementById('sidebar-toggle') + if (toggleBtn) { + toggleBtn.setAttribute('aria-expanded', expandedStr) + } + }, + + destroyed() { + // Cleanup + delete window.toggleSidebar + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, @@ -102,3 +139,170 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +// Sidebar accessibility improvements +document.addEventListener("DOMContentLoaded", () => { + const drawerToggle = document.getElementById("mobile-drawer") + const sidebarToggle = document.getElementById("sidebar-toggle") + const sidebar = document.getElementById("main-sidebar") + + if (!drawerToggle || !sidebarToggle || !sidebar) return + + // Manage tabindex for sidebar elements based on open/closed state + const updateSidebarTabIndex = (isOpen) => { + // Find all potentially focusable elements (including those with tabindex="-1") + const allFocusableElements = sidebar.querySelectorAll( + 'a[href], button, select, input:not([type="hidden"]), [tabindex]' + ) + + allFocusableElements.forEach(el => { + // Skip the overlay button + if (el.closest('.drawer-overlay')) return + + if (isOpen) { + // Remove tabindex="-1" to make focusable when open + if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') { + el.removeAttribute('tabindex') + } + } else { + // Set tabindex="-1" to remove from tab order when closed + if (!el.hasAttribute('tabindex')) { + el.setAttribute('tabindex', '-1') + } else if (el.getAttribute('tabindex') !== '-1') { + // Store original tabindex in data attribute before setting to -1 + if (!el.hasAttribute('data-original-tabindex')) { + el.setAttribute('data-original-tabindex', el.getAttribute('tabindex')) + } + el.setAttribute('tabindex', '-1') + } + } + }) + } + + // Find first focusable element in sidebar + // Priority: first navigation link (menuitem) > other links > other focusable elements + const getFirstFocusableElement = () => { + // First, try to find the first navigation link (menuitem) + const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])') + if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) { + return firstNavLink + } + + // Fallback: any navigation link + const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])') + if (firstLink && !firstLink.closest('.drawer-overlay')) { + return firstLink + } + + // Last resort: any other focusable element + const focusableSelectors = [ + 'button:not([tabindex="-1"]):not([disabled])', + 'select:not([tabindex="-1"]):not([disabled])', + 'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])', + '[tabindex]:not([tabindex="-1"])' + ] + + for (const selector of focusableSelectors) { + const element = sidebar.querySelector(selector) + if (element && !element.closest('.drawer-overlay')) { + return element + } + } + return null + } + + // Update aria-expanded when drawer state changes + const updateAriaExpanded = () => { + const isOpen = drawerToggle.checked + sidebarToggle.setAttribute("aria-expanded", isOpen.toString()) + + // Update dropdown aria-expanded if present + const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]') + if (userMenuButton) { + const dropdown = userMenuButton.closest('.dropdown') + const isDropdownOpen = dropdown?.classList.contains('dropdown-open') + if (userMenuButton) { + userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString()) + } + } + } + + // Listen for changes to the drawer checkbox + drawerToggle.addEventListener("change", () => { + const isOpen = drawerToggle.checked + updateAriaExpanded() + updateSidebarTabIndex(isOpen) + if (!isOpen) { + // When closing, return focus to toggle button + sidebarToggle.focus() + } + }) + + // Update on initial load + updateAriaExpanded() + updateSidebarTabIndex(drawerToggle.checked) + + // Close sidebar with ESC key + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && drawerToggle.checked) { + drawerToggle.checked = false + updateAriaExpanded() + updateSidebarTabIndex(false) + // Return focus to toggle button + sidebarToggle.focus() + } + }) + + // Improve keyboard navigation for sidebar toggle + sidebarToggle.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + const wasOpen = drawerToggle.checked + drawerToggle.checked = !drawerToggle.checked + updateAriaExpanded() + + // If opening, move focus to first element in sidebar + if (!wasOpen && drawerToggle.checked) { + updateSidebarTabIndex(true) + // Use setTimeout to ensure DOM is updated + setTimeout(() => { + const firstElement = getFirstFocusableElement() + if (firstElement) { + firstElement.focus() + } + }, 50) + } else if (wasOpen && !drawerToggle.checked) { + updateSidebarTabIndex(false) + } + } + }) + + // Also handle click events to update tabindex and focus + sidebarToggle.addEventListener("click", () => { + setTimeout(() => { + const isOpen = drawerToggle.checked + updateSidebarTabIndex(isOpen) + if (isOpen) { + const firstElement = getFirstFocusableElement() + if (firstElement) { + firstElement.focus() + } + } + }, 50) + }) + + // Handle dropdown keyboard navigation + const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]') + if (userMenuButton) { + userMenuButton.addEventListener("click", () => { + setTimeout(updateAriaExpanded, 0) + }) + + userMenuButton.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + userMenuButton.click() + } + }) + } +}) + diff --git a/code_review_fixes_-_membership_fee_features_e886dc4b.plan.md b/code_review_fixes_-_membership_fee_features_e886dc4b.plan.md deleted file mode 100644 index eebf419..0000000 --- a/code_review_fixes_-_membership_fee_features_e886dc4b.plan.md +++ /dev/null @@ -1,318 +0,0 @@ ---- -name: Code Review Fixes - Membership Fee Features -overview: Umsetzung der validen Code Review Punkte aus beiden Reviews mit Priorisierung nach Kritikalität. Fokus auf Transaktionssicherheit, Code-Qualität, Performance und UX-Verbesserungen. -todos: - - id: fix-after-action-tasks - content: "after_action mit Task.start → after_transaction + Task.Supervisor: Task.Supervisor zu application.ex hinzufügen, after_action Hooks in after_transaction umwandeln, Task.Supervisor.async_nolink verwenden" - status: pending - - id: reduce-code-duplication - content: "Code-Duplikation reduzieren: handle_cycle_generation/2 private Funktion extrahieren, alle drei Stellen (Create, Type Change, Date Change) verwenden" - status: pending - dependencies: - - fix-after-action-tasks - - id: fix-join-date-validation - content: "join_date Validierung: Entweder Validierung wieder hinzufügen (validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0)) oder Dokumentation anpassen" - status: pending - - id: fix-load-cycles-docs - content: "load_cycles_for_members: Entweder Dokumentation korrigieren (ehrlich machen) oder echte Filterung implementieren (z.B. nur letzte 2 Intervalle)" - status: pending - - id: fix-get-current-cycle-sort - content: "get_current_cycle nondeterministisch: Vor List.first() nach cycle_start sortieren (desc) in MembershipFeeHelpers.get_current_cycle" - status: pending - - id: fix-n1-query-member-count - content: "N+1 Query beheben: Aggregate auf MembershipFeeType definieren oder member_count einmalig vorab laden und in assigns cachen" - status: pending - - id: fix-assign-new-stale - content: "assign_new → assign: In MembershipFeesComponent.update/2 immer assign(:cycles, cycles) und assign(:available_fee_types, available_fee_types) setzen" - status: pending - - id: fix-regenerating-flag - content: "@regenerating auf true setzen: Direkt beim Event-Start in handle_event(\"regenerate_cycles\", ...) socket |> assign(:regenerating, true) setzen" - status: pending - - id: fix-create-cycle-parsing - content: "Create-cycle parsing Fix: Decimal.parse explizit behandeln und {:error, :invalid_amount} zurückgeben statt :error" - status: pending - - id: fix-delete-all-atomic - content: "Delete all cycles atomar: Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) statt Enum.map" - status: pending - - id: improve-async-error-handling - content: "Fehlerbehandlung bei async Tasks: Strukturierte Error-Logs mit Context, optional Retry-Mechanismus oder Event-System für Benachrichtigung" - status: pending - - id: improve-format-currency - content: "format_currency Robustheit: Number.Currency verwenden oder robusteres Pattern Matching + Tests für Edge Cases (negative Zahlen, sehr große Zahlen)" - status: pending - - id: add-missing-typespecs - content: "Fehlende Typespecs: @spec für SetDefaultMembershipFeeType.change/3 hinzufügen" - status: pending - - id: fix-race-condition - content: "Potenzielle Race Condition: Prüfen ob Ash doppelte Auslösung verhindert, ggf. Logik anpassen (beide Änderungen in einem Hook zusammenfassen)" - status: pending - - id: extract-magic-values - content: "Magic Numbers/Strings: Application.get_env(:mv, :sql_sandbox, false) in Konstante/Helper extrahieren (z.B. Mv.Config.sql_sandbox?/0)" - status: pending - - id: fix-domain-consistency - content: "Domain-Konsistenz: Überall in MembershipFeesComponent domain: MembershipFees explizit angeben" - status: pending - - id: fix-test-helper - content: "Test-Helper Fix: create_cycle/3 Helper - Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen" - status: pending - - id: fix-date-utc-today-param - content: "Date.utc_today() Parameter: today Parameter durchgeben in get_cycle_status_for_member und Helper-Funktionen" - status: pending - - id: fix-ui-locale-input - content: "UI/Locale Input Fix: type=\"number\" → type=\"text\" + inputmode=\"decimal\" + serverseitig \",\" → \".\" normalisieren" - status: pending - - id: fix-delete-confirmation - content: "Delete-all-Confirmation robuster: String.trim() + case-insensitive Vergleich oder \"type DELETE\" Pattern" - status: pending - - id: fix-warning-state - content: "Warning-State Fix: Bei Decimal.parse(:error) explizit hide_amount_warning(socket) aufrufen" - status: pending - - id: fix-double-toggle - content: "Toggle entfernen: Toggle-Button im Spalten-Header entfernen (nur in Toolbar behalten)" - status: pending - - id: fix-format-consistency - content: "Format-Konsistenz: Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren" - status: pending - dependencies: - - fix-ui-locale-input ---- - -# Code Review Fixes - Membership Fee Features - -## Kritische Probleme (Müssen vor Merge behoben werden) - -### 1. after_action mit Task.start - Transaktionsprobleme - -**Dateien:** `lib/membership/member.ex` (Zeilen 142, 279) - -**Problem:** `Task.start/1` wird innerhalb von `after_action` Hooks verwendet. `after_action` läuft innerhalb der DB-Transaktion, daher: - -- Tasks sehen möglicherweise noch nicht committed state -- Tasks werden auch bei Rollback gestartet -- Keine Supervision → Memory Leaks möglich - -**Lösung:** - -- `after_transaction` Hook verwenden (Ash Best Practice) -- `Task.Supervisor` zum Supervision Tree hinzufügen (`lib/mv/application.ex`) -- `Task.Supervisor.async_nolink/3` statt `Task.start/1` verwenden - -**Betroffene Stellen:** - -- Member Creation (Zeile 116-164) -- Join/Exit Date Change (Zeile 250-301) - -### 2. Code-Duplikation in Cycle-Generation-Logik - -**Datei:** `lib/membership/member.ex` - -**Problem:** Cycle-Generation-Logik ist dreimal dupliziert (Create, Type Change, Date Change) - -**Lösung:** Extrahiere in private Funktion `handle_cycle_generation/2` - -## Wichtige Probleme (Sollten behoben werden) - -### 3. join_date Validierung entfernt, aber Dokumentation behauptet Gegenteil - -**Datei:** `lib/membership/member.ex` (Zeile 27, 516-518) - -**Problem:** Dokumentation sagt "join_date not in future", aber Validierung fehlt - -**Lösung:** Dokumentation anpassen - -### 4. load_cycles_for_members overpromises - -**Datei:** `lib/mv_web/member_live/index/membership_fee_status.ex` (Zeile 36-40) - -**Problem:** Dokumentation sagt "Only loads the relevant cycle per member" und "Filters cycles at database level", aber lädt alle Cycles - -**Lösung:** echte Filterung implementieren (z.B. nur letzte 2 Intervalle) - -### 5. get_current_cycle nondeterministisch - -**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeile 178-182) - -**Problem:** `List.first()` ohne explizite Sortierung → Ergebnis hängt von Reihenfolge ab - -**Lösung:** Vor `List.first()` nach `cycle_start` sortieren (desc) - -### 6. N+1 Query durch get_member_count - -**Datei:** `lib/mv_web/live/membership_fee_type_live/index.ex` (Zeile 134-140) - -**Problem:** `get_member_count/1` wird pro Row aufgerufen → N+1 Query - -**Lösung:** Aggregate auf MembershipFeeType definieren oder einmalig vorab laden - -### 7. assign_new kann stale werden - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 402-403) - -**Problem:** `assign_new(:cycles, ...)` und `assign_new(:available_fee_types, ...)` werden nur gesetzt, wenn Assign noch nicht existiert - -**Lösung:** In `update/2` immer `assign(:cycles, cycles)` / `assign(:available_fee_types, available_fee_types)` setzen - -### 8. @regenerating wird nie auf true gesetzt - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 526-561) - -**Problem:** `regenerating` wird nur auf `false` gesetzt, nie auf `true` → Button/Spinner werden nie disabled - -**Lösung:** Direkt beim Event-Start `socket |> assign(:regenerating, true)` setzen - -### 9. Create-cycle parsing: invalid amount zeigt falsche Fehlermeldung - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 748-812) - -**Problem:** `Decimal.parse/1` gibt `:error` zurück, aber `with` behandelt es als `:error` → landet in "Invalid date format" Branch - -**Lösung:** Explizit `{:error, :invalid_amount}` zurückgeben: - -```elixir -amount = case Decimal.parse(amount_str) do - {d, _} -> {:ok, d} - :error -> {:error, :invalid_amount} -end -``` - -### 10. Delete all cycles: nicht atomar - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 666-714) - -**Problem:** `Enum.map(cycles, &Ash.destroy/1)` → nicht atomar, teilweise gelöscht möglich - -**Lösung:** Bulk Delete Query verwenden (Ash bulk destroy oder Query-basiert) - -### 11. Fehlerbehandlung bei async Tasks - -**Datei:** `lib/membership/member.ex` - -**Problem:** Bei Fehlern in async Tasks wird nur geloggt, aber der Benutzer erhält keine Rückmeldung. Die Member-Aktion wird als erfolgreich zurückgegeben, auch wenn die Cycle-Generierung fehlschlägt. Keine Retry-Logik oder Monitoring. - -**Lösung:** - -- Für kritische Fälle: synchron ausführen oder Retry-Mechanismus implementieren -- Für nicht-kritische Fälle: Event-System für spätere Benachrichtigung -- Strukturierte Error-Logs mit Context -- Optional: Error-Tracking (Sentry, etc.) - -### 12. format_currency Robustheit - -**Datei:** `lib/mv_web/helpers/membership_fee_helpers.ex` (Zeilen 27-51) - -**Problem:** Die Funktion verwendet String-Manipulation für Formatierung. Edge Cases könnten problematisch sein (z.B. sehr große Zahlen, negative Werte). - -**Lösung:** - -- `Number.Currency` oder ähnliche Bibliothek verwenden -- Oder: Robusteres Pattern Matching für Edge Cases -- Tests für Edge Cases hinzufügen (negative Zahlen, sehr große Zahlen) - -### 13. Fehlende Typespecs - -**Datei:** `lib/membership/member/changes/set_default_membership_fee_type.ex` - -**Problem:** Keine `@spec` für die `change/3` Funktion. - -**Lösung:** Typespecs hinzufügen für bessere Dokumentation und Dialyzer-Support. - -### 14. Potenzielle Race Condition - -**Datei:** `lib/membership/member.ex` (Zeile 250-301) - -**Problem:** Wenn `join_date` und `exit_date` gleichzeitig geändert werden, könnte die Cycle-Generierung zweimal ausgelöst werden (einmal pro Änderung). - -**Lösung:** Prüfen, ob Ash dies bereits verhindert, oder Logik anpassen (z.B. beide Änderungen in einem Hook zusammenfassen). - -### 15. Magic Numbers/Strings - -**Problem:** `Application.get_env(:mv, :sql_sandbox, false)` wird mehrfach verwendet. - -**Lösung:** Extrahiere in Konstante oder Helper-Funktion (z.B. `Mv.Config.sql_sandbox?/0`). - -## Mittlere Probleme (Nice-to-have) - -### 16. Inconsistent use of domain - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 819-821) - -**Problem:** Einige Actions verwenden `domain: MembershipFees`, andere nicht - -**Lösung:** Konsistent `domain` überall verwenden - -### 17. Tests: create_cycle/3 löscht jedes Mal alle Cycles - -**Datei:** `test/mv_web/member_live/index/membership_fee_status_test.exs` (Zeile 45-52) - -**Problem:** Helper löscht vor jedem Create alle Cycles → Tests prüfen nicht, was sie denken - -**Lösung:** Cleanup nur einmal im Setup oder gezielt nur auto-generierte löschen - -### 18. Tests/Design: Date.utc_today() macht Tests flaky - -**Problem:** Tests hängen von `Date.utc_today()` ab → nicht deterministisch - -**Lösung:** `today` Parameter durchgeben (z.B. `get_cycle_status_for_member(member, show_current, today \\ Date.utc_today())`) - -### 19. UI/Locale: input type="number" + Decimal/Komma - -**Problem:** `type="number"` funktioniert nicht zuverlässig mit Komma als Dezimaltrenner - -**Lösung:** `type="text"` + `inputmode="decimal"` + serverseitig "," → "." normalisieren - -### 20. Delete-all-Confirmation: String-Vergleich ist fragil - -**Datei:** `lib/mv_web/live/member_live/show/membership_fees_component.ex` (Zeile 296-298) - -**Problem:** String-Vergleich gegen `gettext("Yes")` und `"Yes"` → fragil bei Whitespace/Locale - -**Lösung:** `String.trim()` + case-insensitive Vergleich oder "type DELETE" Pattern - -### 21. MembershipFeeType Form: Warning-State kann hängen bleiben - -**Datei:** `lib/mv_web/live/membership_fee_type_live/form.ex` (Zeile 367-378) - -**Problem:** Bei `Decimal.parse(:error)` wird nur `socket` zurückgegeben → Warning kann stehen bleiben - -**Lösung:** Bei `:error` explizit `hide_amount_warning(socket)` aufrufen - -### 22. UI/UX: Toggle ist doppelt vorhanden - -**Datei:** `lib/mv_web/live/member_live/index.html.heex` (Zeile 45-72, 284-296) - -**Problem:** Toggle-Button sowohl in Toolbar als auch im Spalten-Header - -**Lösung:** Toggle im Spalten-Header entfernen (nur in Toolbar behalten) - -### 23. Konsistenz: format_currency vs Inputs - -**Problem:** `format_currency` formatiert deutsch (Komma), aber Inputs erwarten Punkt - -**Lösung:** Inputs ebenfalls auf Komma ausrichten oder serverseitig normalisieren - -## Implementierungsreihenfolge - -1. **Kritisch:** after_action → after_transaction + Task.Supervisor -2. **Kritisch:** Code-Duplikation reduzieren -3. **Wichtig:** join_date Validierung/Dokumentation -4. **Wichtig:** load_cycles_for_members Dokumentation/Implementierung -5. **Wichtig:** get_current_cycle Sortierung -6. **Wichtig:** N+1 Query beheben -7. **Wichtig:** assign_new → assign -8. **Wichtig:** @regenerating auf true setzen -9. **Wichtig:** Create-cycle parsing Fix -10. **Wichtig:** Delete all cycles atomar -11. **Wichtig:** Fehlerbehandlung bei async Tasks -12. **Wichtig:** format_currency Robustheit -13. **Wichtig:** Fehlende Typespecs -14. **Wichtig:** Potenzielle Race Condition prüfen/beheben -15. **Wichtig:** Magic Numbers/Strings extrahieren -16. **Mittel:** Domain-Konsistenz -17. **Mittel:** Test-Helper Fix -18. **Mittel:** Date.utc_today() Parameter -19. **Mittel:** UI/Locale Fixes -20. **Mittel:** String-Vergleich robuster -21. **Mittel:** Warning-State Fix -22. **Mittel:** Toggle entfernen -23. **Mittel:** Format-Konsistenz - diff --git a/docker-compose.yml b/docker-compose.yml index 8621603..4c169b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: mv_dev volumes: - - type: volume - source: postgres-data - target: /var/lib/postgresql/data - volume: - nocopy: true + - postgres-data:/var/lib/postgresql/data ports: - "5000:5432" networks: @@ -49,9 +45,7 @@ services: - rauthy-dev - local volumes: - - type: volume - source: rauthy-data - target: /app/data + - rauthy-data:/app/data volumes: postgres-data: diff --git a/docs/csv-member-import-v1.md b/docs/csv-member-import-v1.md index 2bdbe69..bc8f99f 100644 --- a/docs/csv-member-import-v1.md +++ b/docs/csv-member-import-v1.md @@ -191,6 +191,26 @@ A **basic CSV member import feature** that allows administrators to upload a CSV - `/templates/member_import_de.csv` - In LiveView, link them using Phoenix static path helpers (e.g. `~p` or `Routes.static_path/2`, depending on Phoenix version). +**Example Usage in LiveView Templates:** + +```heex + +<.link href={~p"/templates/member_import_en.csv"} download> + <%= gettext("Download English Template") %> + + +<.link href={~p"/templates/member_import_de.csv"} download> + <%= gettext("Download German Template") %> + + + +<.link href={Routes.static_path(MvWeb.Endpoint, "/templates/member_import_en.csv")} download> + <%= gettext("Download English Template") %> + +``` + +**Note:** The `templates` directory must be included in `MvWeb.static_paths()` (configured in `lib/mv_web.ex`) for the files to be served. + ### File Limits - **Max file size:** 10 MB diff --git a/docs/daisyui-drawer-pattern.md b/docs/daisyui-drawer-pattern.md new file mode 100644 index 0000000..dec599d --- /dev/null +++ b/docs/daisyui-drawer-pattern.md @@ -0,0 +1,533 @@ +# DaisyUI Drawer Pattern - Standard Implementation + +This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination. + +## Core Concept + +DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript. + +### Key Components + +1. **`drawer`** - Container element +2. **`drawer-toggle`** - Hidden checkbox that controls open/close state +3. **`drawer-content`** - Main content area +4. **`drawer-side`** - Sidebar content (menu, navigation) +5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click) + +## HTML Structure + +```html +
+ + + + +
+ + +
+ + +
+ + +
+
+``` + +## How drawer-toggle Works + +### Mechanism + +The `drawer-toggle` is a **hidden checkbox** that serves as the state controller: + +```html + +``` + +### Toggle Behavior + +1. **Label Connection**: Any `