Bulk actions redesign: per-action modals + endpoints (supersedes #420)#492
Draft
jorisjonkers-dev-agents[bot] wants to merge 123 commits into
Draft
Bulk actions redesign: per-action modals + endpoints (supersedes #420)#492jorisjonkers-dev-agents[bot] wants to merge 123 commits into
jorisjonkers-dev-agents[bot] wants to merge 123 commits into
Conversation
jorisjonkers-dev-agents
Bot
force-pushed
the
feat/bulk-actions-redesign
branch
from
July 18, 2026 11:49
dcec648 to
ceb5535
Compare
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
* feat(api): bulk mark paid/unpaid + end-membership endpoints (preview/execute) Adds the shared two-phase bulk-action envelope and the first two bulk endpoints for the member manager, both board-only and applied in a single request with all logic server-side: - shared/dto/bulk: BulkPreviewResult/BulkActionResult envelope (counts + per-user disposition rows), reused by later bulk actions. - POST /contributions/bulk/preview|execute: mark paid/unpaid for a period; preview reports will-apply vs already-in-state, execute creates/deletes contributions and skips no-ops. - POST /memberships/bulk/end/preview|execute: end all active memberships of the selected users effective today; skips users with no active membership. System tests (Testcontainers) cover preview dispositions, execute DB effects, and board-only authorization. Part of #414. Implements #415. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * chore(api): regenerate OpenAPI spec + frontend client for bulk endpoints Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <agents@jorisjonkers.dev> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…k endpoints) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…isable) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…433) * chore: integration branch for member manager bulk actions (epic #414) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * PR2: bulk contribution reminders with fee resolution Implements user story 4 (bulk contribution reminders) with per-member-type fee resolution, exclusions for honorary members, and optional re-inclusion of already-paid members. Key features: - Fee resolution: resolveMemberFee() determines regular (half/full-year based on cutoff), alumni, or honorary (excluded) fees. Reusable by future incasso feature. - Preview endpoint: POST /contributionReminders/bulk/preview returns per-user disposition (INCLUDED/EXCLUDED_HONORARY/WARNING_ALREADY_PAID), resolved amount, and lastSentOn audit timestamp. - Execute endpoint: POST /contributionReminders/bulk/execute honours per-user amount overrides and includedUserIds re-includes, writes ContributionReminder audit rows with resolved amount and paymentDueDate, enqueues emails, returns applied/skipped/queued counts. - Email content: Updated to quote single resolved amount + payment-due date (no longer lists all options) for bulk reminders. Single-user reminders unchanged (backward compat). - Authorization: Board-only (@PreAuthorize hasPermission ContributionReminder write). - Database: Migration adds amount and payment_due_date columns to contribution_reminders table. Extends shared envelope (PR1) + mirrored ContributionBulkController pattern (preview→execute, CQRS, MockMvc integration tests asserting email body content). No OpenAPI regen (DB env required); flag for manual local run. Co-Authored-By: JorisJonkers-dev Agent <agents@jorisjonkers.dev> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(api): fix honorary preview assertion for null amount The shared Jackson config omits null properties (Include.NON_NULL), so an excluded honorary row serialises without an "amount" key at all. The preview test asserted jsonPath("$.rows[0].amount").isEmpty, which requires the path to exist and thus threw PathNotFoundException. Switch to doesNotExist(), the correct matcher for an absent JSON key. Behaviour under test is unchanged. Co-Authored-By: JorisJonkers-dev Agent <agents@jorisjonkers.dev> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * chore: regenerate OpenAPI spec/client for bulk contribution reminder endpoints Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * chore: normalize generated client via lint:gen (strip unused eslint-disable) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <agents@jorisjonkers.dev> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev> Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com>
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Implements PR3 of the bulk member manager feature (epic #414). Adds bulk incasso notification endpoints that follow PR2's pattern. Domain: - IncassoNotification entity & repository (audit trail) - IncassoNotificationService with email job dispatch - BulkIncassoNotificationCommands (Preview/Execute) - Handlers with disposition logic: HONORARY → EXCLUDED (no mail), active members NOT marked incasso=true → WARNING (overridable), already paid → WARNING (overridable), else INCLUDED - Email: new IncassoNotificationEmailBuilder with resolved amount + expectedIncassoDate (collection date) Endpoints: - POST /incassoNotifications/bulk/preview → BulkPreviewResult - POST /incassoNotifications/bulk/execute → BulkActionResult - @PreAuthorize reuses Contribution.write permission (no new resource) - Request: userIds, contributionPeriodId, cutoffDate, expectedIncassoDate, includedUserIds, amountOverrides Persistence: - Migration V81: incasso_notifications table (composite key: user_id, contribution_period_id; soft-delete, timestamps) - Reuses FeeResolution.resolveMemberFee() from PR2 verbatim - Mirrors ContributionReminder audit pattern Tests: - BulkIncassoNotificationHandlersTest: 8 unit tests covering preview/execute disposition rules, amount overrides, re-inclusion via includedUserIds - Updated EmailServiceMissingEntityTest with incasso notification 404 test Stacks on PR2 (feat/mba-pr2-reminder); will retarget to integration branch. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…ndpoints Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Adds IncassoNotificationBulkControllerIT mirroring PR2's ContributionReminderBulkControllerIT: @SpringBootTest + MockMvc, the shared UserTestSupport/ServiceTestSupport fixtures, and end-to-end verification of the endpoints, @PreAuthorize, JSON contract, real persistence and email-job enqueue. Coverage (~16 cases): - Preview: regular full/half-year fee, alumni fee, honorary EXCLUDED, incasso-mismatch WARNING, already-paid WARNING, mixed-selection counts, 404 unknown period, 400 empty selection - Execute: writes IncassoNotification audit row (amount + expectedIncassoDate) and enqueues the email job; amount overrides honored in audit and rendered mail; honorary never sent; incasso-mismatch and already-paid excluded by default but sent when re-included; member without email skipped - Authorization: non-BOARD rejected on preview and execute - Mail content: rendered body asserted to contain the resolved amount and the formatted expected-incasso date via InMemoryEmailClient Migration fix surfaced by the full-stack mapping: V81 was missing the audit FK columns required by AuditedVersionedEntity. The table now mirrors contribution_reminders exactly — composite (user_id, contribution_period_id) identity, soft-delete sentinel, version default 0, and created_by_id / updated_by_id FKs to users — validated against MariaDB 10.11.10. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
* feat(frontend): member manager bulk-actions UI (#418) Implements the full selection + bulk-action UI in MemberManager.vue, stacking on the PR1–PR3 bulk endpoints (incl. incasso) and the regenerated OpenAPI client. - `useMemberSelection` composable: persistent `Set<userId>` survives filter/sort changes; per-row toggle; tri-state header checkbox (none/some/all) computed against displayed rows only; `toggleHeader` adds/removes only displayed rows. - `BulkActionsMenu.vue`: triple-dot icon button at the toolbar right; enabled only when ≥1 user selected; 5 items (mark paid, mark unpaid, contribution reminder, incasso notification, end membership); period-relative items disabled when no period is selected. - `BulkActionConfirmDialog.vue`: generic preview→execute dialog covering all 5 actions via a config object. Calls the action's preview endpoint on open; renders per-user rows with disposition colour-coding (INCLUDED=normal, EXCLUDED=red, WARNING=amber, SKIPPED=dimmed); per-row re-include checkbox for WARNING rows; per-row editable amount for reminder/incasso actions; required date inputs (paymentDueDate / expectedIncassoDate + halfYearCutoffDate) conditional per action; on confirm calls execute and clears selection. - Wire checkboxes into MemberManager.vue rows + header; add BulkActionsMenu to toolbar; selection-count chip + clear button; selection clears on `onBeforeUnmount` and after successful execution; refresh users + memberships after execute. - Full `data-testid` coverage for checkboxes, menu, menu items, dialog, per-row disposition, re-include checkboxes, date inputs, confirm button. - 16 new unit tests for `useMemberSelection` (persistent selection, tri-state logic, toggleHeader only touches displayed rows). - 12 new Playwright e2e tests covering select rows → menu → preview → confirm → success flow for both contribution-reminder and end-membership, plus selection persistence across filter changes. Screenshots in `pr4-screenshots/`. Closes #418. Part of epic #414. Stacks on PR3 (feat/mba-pr3-incasso). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * chore: drop review-only screenshots from PR Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…ings Valkey backs only HTTP sessions and the cache layer; no @RedisHash entities or Redis repositories exist. With both the Redis and JPA Spring Data modules on the classpath, strict repository-configuration mode had the Redis module inspect all 39 JPA repositories and log a 'Could not safely identify store assignment' INFO line for each at startup. Setting spring.data.redis.repositories.enabled=false stops that scan (it found 0 Redis repositories anyway); sessions and caching are unaffected. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
… incasso-notification fee resolution
Replace membership selection logic in both reminder and incasso bulk action handlers:
- OLD: firstOrNull { it.endDate == null } — incorrect when multiple active memberships
- NEW: maxByOrNull { it.startDate } — uses most-recent membership by start date
This ensures the half-year vs full-year fee decision and member-type resolution
are based on the member's current membership, not an arbitrary one.
Also add memberSince: LocalDate? to BulkPreviewRow (after memberType) to surface
the membership start date that drove the fee calculation. Populate it in:
- PreviewBulkIncassoNotificationHandler (incasso action preview)
- PreviewBulkContributionReminderHandler (reminder action preview)
- PreviewBulkEndMembershipHandler (best-effort; no membership data in mark-paid/unpaid)
This allows the frontend to display why half vs full-year was applied.
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
- Fix #438: Increase dialog max-width to 960 and add "Member since" column (placed between Status and Amount, formatted as dd/MM/yyyy ISO dates). - Fix #436: Replace raw disposition reason enum with human-readable labels (INCASSO_MISMATCH → "Not marked for incasso", ALREADY_PAID → "Already paid", HONORARY → "Honorary — no contribution needed"). - Fix #437: Enable submit button only when at least one row is effectively included (INCLUDED or re-included WARNING); make "will apply" count reflect live effective inclusion (includedUserIds.length). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…election toggle (issues #439 #440) FIX A (issue #439): After a bulk action (mark-paid/mark-unpaid), the paid column now correctly reflects the changes without requiring a manual reload. Added reloadPaid() to usePaidToggle composable that re-fetches contributions for the current period. The onBulkDone() handler now calls this alongside getUsers() and getMemberships() to ensure the paid status is synchronized. FIX B (issue #440): When at least one member is selected (selection mode active), clicking anywhere on a row body now toggles that row's selection, except for interactive controls (buttons, links, inputs, checkboxes). - Added isClickOnInteractiveTarget() helper that checks if a click target is on button, a, input, label, .v-selection-control, or [role=button] - Added onRowClick() handler that only toggles selection if: 1. Selection mode is active (selectedIdsArray.length > 0) 2. Click is not on an interactive control - Applied to both desktop rows and mobile rows with cursor:pointer styling When selection mode is active, rows display cursor:pointer. When no members are selected, row-body clicks are NO-OPs (normal browsing behavior). Author: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
On desktop the bulk-actions menu now sits on the right of the member table's header row (the Actions column header) rather than beside the toolbar buttons. The toolbar menu is retained on mobile only, which has no table header. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
* test(system): bring bulk-actions harness + reminder flow onto integration MemberManagerBulkHelper + smoke test + contribution-reminder flow, rebased onto the current integration branch (recovered from the pre-crash feat/mba-systemtests / feat/mba-st-reminder branches). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(system): add bulk-action flow system tests + robust note locator (#441) Adds full-stack Playwright system tests for the member-manager bulk flows: mark paid/unpaid, end membership, incasso notification, selection semantics (incl. row-body click), and authorization/empty-recipient paths — driving the real UI via MemberManagerBulkHelper and asserting backend effects. Also hardens the harness after the dialog gained a 'Member since' column and a conditional amount column: adds a bulk-preview-note-<userId> testid to the dialog and switches MemberManagerBulkHelper.reasonOf from a fixed column index to that testid. Reminder-flow reason assertions updated to the human-readable text. TestHelper gains findMembershipsForUser for end-membership assertions. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(system): fix disposition case + authz route/gate assertions (#441) - dispositionOf normalizes the chip's humanized label ('Included'/'Excluded'/ 'Warning'/'Skipped') to the BulkRowDisposition enum name callers assert, fixing the mark-paid/incasso/reminder/end-membership disposition checks. - Authz access tests targeted a non-existent route (/management/members) and the wrong API path (/api/members). The member manager is at /members/manage and loads from the BOARD-only GET /api/users; the tests now navigate there and assert that call is rejected (401/403) with no member rows rendered. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(system): correct bulk-action flow tests against real behavior (#441) CI surfaced genuine mismatches; fixed on the correct side each time: - Dialog: a re-included WARNING row now reads as Included (effective disposition), with the original reason still shown — the row will be acted on, so it should present as included. (code = the behavior the tests specify) - Harness: waitForSuccess waited on Locator.isVisible(), which returns false without throwing, so it never detected completion; now waits for the confirm dialog to become hidden. dispositionOf normalizes the humanized chip label to the enum name. - Tests: mark-paid on an already-paid member is an idempotent no-op, so it is SKIPPED (not a re-includable WARNING — that is reminder/incasso semantics); fee assertions compare numeric value rather than Double.toString() formatting; selection test used the wrong testid (bulk-selection-count). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(system): target Vuetify inner inputs and pin the previewed period (#441) Two harness-correctness fixes surfaced by CI (feature code is unaffected): - The amount field and re-include checkbox carry their data-testid on the Vuetify wrapper, not the inner control, so inputValue()/fill()/click() hit a <div> ('Node is not an <input>') and the re-include toggle never flipped the model. Reads/writes now go through TestIdLocatorHelper.textInput (inner <input>), and a new amountOf() reader replaces direct wrapper reads. - Bulk previews for paid status / reminders / incasso are period-sensitive, but the member manager auto-selects the latest period. In parallel shards that is often not the test's period, so an already-paid member read as unpaid. Tests now pin their period via a new MemberManagerBulkHelper.selectPeriod(). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…445) (#448) Replace the inline v-for row markup in MemberManager with dedicated MemberManagerRow (desktop) and MemberManagerMobileRow (mobile) child components so Vue can skip patching siblings whose props are unchanged. Root cause: toggle() replaced the entire selectedIds Set, invalidating all rows' isSelected(row.id) bindings and causing O(n) Vuetify checkbox re-patches per click (~305 rows = visible lag). Fix: each row now receives only a boolean :selected prop; siblings whose selected/selectionActive/row/isSaving props are unchanged are skipped by Vue's reconciler on every toggle. All data-testids, row-click-to-select behaviour, mm-row--selected class, and pointer-cursor affordance are preserved. Adds MemberManagerSelectionPerf.test.ts (mount + onUpdated counter via defineExpose) as a regression guard: RED before extraction, GREEN after. Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
Add a shared, contract-driven memberTypeLabel formatter and route every member-type display through it. - utils/memberType.ts: memberTypeLabel(type) backed by an exhaustive Record<MemberType, string> keyed on the generated OpenAPI enum, so a backend enum change fails typecheck until the map is updated. Maps REGULAR/ALUMNI/HONORARY/NONE to title-case, null/undefined to "—", and defensively title-cases any value outside the enum. - BulkActionConfirmDialog.vue: Type column now renders memberTypeLabel(row.memberType) instead of the raw uppercase enum. - ManageMembershipDialog.vue: active and deleted membership rows use memberTypeLabel(m.memberType) instead of .toLowerCase()/text-capitalize. - Unit tests assert against the generated MemberType enum values plus null/undefined and unknown-value fallback. Closes #446 Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
Add BulkRowReason and BulkActionType enums to replace magic string literals: - BulkRowReason: ALREADY_PAID, NOT_PAID, HONORARY, INCASSO_MISMATCH, NO_ACTIVE_MEMBERSHIP, STARTED_TODAY - BulkActionType: MARK_PAID, MARK_UNPAID, CONTRIBUTION_REMINDER, INCASSO_NOTIFICATION, END_MEMBERSHIP Change BulkPreviewRow.reason from String? to BulkRowReason?. Change BulkPreviewResult.action from String to BulkActionType. Update all bulk handlers (contribution, reminder, incasso, membership) to emit enums instead of string literals. Update tests and frontend BulkActionConfirmDialog to use the typed enums. Regenerate OpenAPI schema & client (byte-stable). Claude-Session: https://claude.ai/code/session_01MpC8KLscYCyBWQg6mPw8Rd Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…453) (#461) * Member manager header: remove selection chips, align header checkbox (#453) Removed the "N selected" chip and "CLEAR" button from the MEMBERS header area. These UI elements have been removed while keeping the clearSelection() function (called after bulk actions). Selection state is now tracked via the header checkbox and bulk-actions menu gating instead. Fixed the header select-all checkbox vertical alignment by adding flexbox centering classes to the <th> element. The checkbox now aligns properly with other header cells instead of rendering higher. System test updated to verify selection via row checkboxes and bulk-actions menu gating instead of the removed chips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpC8KLscYCyBWQg6mPw8Rd Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * fix(frontend): drop now-unused selectionCount after removing the selected chip Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(e2e): update member-manager bulk spec after removing selection chips (#453) Replaced assertions on the removed 'bulk-selection-count' chip and 'bulk-selection-clear' button with equivalent checkbox-based selection assertions across all affected tests. Selection is now verified via: - Row checkbox input checked/unchecked state (per-row or specific ID) - Header checkbox tri-state behavior - Bulk actions menu enabled/disabled gating Tests updated: - selecting rows enables bulk-actions menu: assert specific row checkboxes checked - header checkbox selects all visible rows: assert all 3 row checkboxes checked - deselecting rows via header checkbox resets selection (renamed, was 'clear selection button'): deselect all via header and assert all unchecked - opens preview dialog with correct dispositions: assert 3 checkboxes checked - contribution reminder full flow: assert 3 checkboxes checked, then all unchecked after execution - end membership opens dialog with preview...: assert 2 checkboxes checked, then unchecked after execution - end membership cancel closes dialog...: assert row 51 still checked - selected rows persist when search filter is applied: assert row 51 still checked and menu enabled despite 52/53 hidden All checkbox assertions use .locator('input') to access the inner input element, matching the Vuetify v-checkbox structure. Co-Authored-By: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…chips (#454) (#463) * Member manager mobile: drop selection/bulk-menu/paid-toggle, clarify chips Implements #454 for mobile-only cleanup: 1. Removed row selection checkbox from mobile rows (MemberManagerMobileRow.vue) 2. Removed mark-as-paid/mark-as-unpaid toggle button from mobile rows 3. Removed bulk-actions menu from mobile toolbar (only shown on desktop) 4. Updated status chips on mobile rows: - First chip now shows the member's ROLE (same source as desktop Role column) - Second chip now shows "Member" / "Not member" based on active membership status (status === 'Current') instead of period-relative "In period" / "Not in period" - Both chips maintain sensible colors (green for active/member, grey for inactive) 5. Updated e2e tests to skip on mobile-chrome project (bulk selection tests are desktop-only) Desktop layout and desktop bulk actions remain unchanged. Co-Authored-By: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * Skip mobile selection perf test (mobile selection removed in #454) Mobile row selection was removed per #454, so the mobile test case that tries to emit toggle-selection no longer applies. Desktop selection performance testing remains valid. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> * test(e2e): fix beforeEach fixtures destructure so the mobile-skip hook runs (#454) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev> --------- Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…or bulk reminder & incasso (#444) (#468) Adds BulkFeeType enum (FULL_YEAR_FEE / HALF_YEAR_FEE / ALUMNI_FEE) so the server always resolves the € from the period's canonical fees for the chosen type, eliminating arbitrary free-amount entry. Backend: - BulkActionEnvelope: add BulkFeeType @Schema enum + recommendedFeeType on BulkPreviewRow - FeeResolution: add resolveFeeType (type from member+cutoff) and resolveFeeAmount (€ from type+period) - Request DTOs: replace amountOverrides: Map<Long,Double> → feeTypeOverrides: Map<Long,BulkFeeType> - Commands: same replacement (BulkContributionReminderCommands, BulkIncassoNotificationCommands) - Handlers: populate recommendedFeeType in preview; resolve € via resolveFeeAmount in execute - Controller: wire feeTypeOverrides through Frontend: - types.gen.ts: add feeTypeOverrides + recommendedFeeType to generated types (regen) - BulkActionConfirmDialog: replace number input with v-select (fee type), default to recommendedFeeType, warn-on-change with amber icon + tooltip, send feeTypeOverrides on execute - E2e spec: update REMINDER_PREVIEW fixture with recommendedFeeType Tests: - ContributionReminderBulkControllerIT: assert recommendedFeeType in preview; feeTypeOverride in execute - IncassoNotificationBulkControllerIT: same - BulkIncassoNotificationHandlersTest: update execute-applies-override test to fee type - MemberManagerBulkHelper: setAmountOverride → chooseFeeType/selectedFeeTypeLabel + updated amountOf - MemberManagerBulkReminderSystemTest: amount-override test → fee-type-override test Closes #444 Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…ble (#447) (#469) Add reusable tri-state table sort composable (useTableSort): - Generic, accepts items ref + comparators map - Cycles: ascending → descending → none on column header clicks - Provides sortedItems, sortKey, sortDir, toggleSort, sortIcon, ariaSort - Handles null values consistently (sorts last) Wire into BulkActionConfirmDialog: - Added sortable columns: name, memberType (type), disposition (status), memberSince - Sort affordance icons on headers with keyboard support - Uses sortedItems for row rendering Refactor MemberManager main table sort onto composable: - useMemberFilters now uses useTableSort internally - Maintains identical public API (sortKey, sortAsc, toggleSort, sortIcon) - Preserves all existing behavior: tri-state cycle, icon display, aria-sort - Backward compatible: tests and template unchanged Unit tests: - useTableSort: tri-state cycle, null handling, icon/aria-sort generation (13 tests) - useMemberFilters: existing behavior preserved, all 20 tests passing Regression coverage: existing useMemberFilters.test.ts green, new useTableSort.test.ts. Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com> Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
Implements a board-only two-phase (preview → execute) bulk action to
resume or start memberships for a selection of users.
Rules per user:
- RESUME: latest membership ended within the most-recent ContributionPeriod
→ endDate cleared (membership reactivated in place).
- START NEW: no resumable membership found (ended before the period, or no
prior membership at all) → insert new Membership(startDate=today) copying
memberType and incasso from the latest prior row (or REGULAR/false if none).
- ALREADY ACTIVE (endDate == null) → SKIPPED.
- No ContributionPeriod exists at all → all rows SKIPPED.
Backend:
- BulkRowReason: +ALREADY_ACTIVE, +NO_CONTRIBUTION_PERIOD, +WILL_RESUME, +WILL_START_NEW
- BulkActionType: +RESUME_MEMBERSHIP
- BulkResumeMembershipCommands (Preview + Execute)
- BulkResumeMembershipCommandHandlers (Preview + Execute)
- BulkResumeMembershipRequest DTO
- POST /memberships/bulk/resume/preview + /execute on MembershipBulkController
- openapi.json + frontend TypeScript client regenerated
Frontend:
- BulkActionsMenu.vue: +resumeMembership emit + list item (mdi-account-reactivate)
- BulkActionConfirmDialog.vue: +resumeMembership to BulkActionKind, actionConfig,
loadPreview, onConfirm; +WILL_RESUME/WILL_START_NEW/ALREADY_ACTIVE/NO_CONTRIBUTION_PERIOD
to reason labels (shown in Note column)
- MemberManager.vue: wire @resume-membership → openBulkAction('resumeMembership')
Tests:
- BulkResumeMembershipHandlersTest (10 unit tests, all green)
- MembershipBulkResumeControllerIT (preview/execute/auth IT tests)
- MemberManagerBulkResumeSystemTest (resume, start-new, no-prior, already-active)
- MemberManagerBulkHelper: +resume-membership action mapping
Co-authored-by: JorisJonkers-dev Agent <joris.jonkers@nedap.com>
Co-authored-by: Joris Jonkers <info@jorisjonkers.dev>
…ight flake The API container runs in Europe/Amsterdam (docker-compose.ci.yml TZ), so it stamps new memberships with the Amsterdam date. The system test asserted against the test JVM's default zone (UTC), diverging by a day when a run straddled midnight Amsterdam (observed: expected 2026-07-17 but was 2026-07-18). Assert against the server's zone. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
isVisible() returns immediately and flaked when the assertion ran before the bulk-menu overlay finished painting. waitFor() polls until each action item is visible. Also fixes withFailMessage ordering (it came after isTrue, so it never applied). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
- Add docs/proposals/bulk-actions/REDESIGN.md (consolidated design). - Extract per-action decide() functions (decideReminder/decideIncasso) shared by preview and execute so they can no longer diverge; surface NO_EMAIL as a first-class SKIPPED reason (ends the silent email-blank skip). - Add fee-override validation on execute (400 for excluded/non-included users). - Rename contribution bulk endpoints to action paths; drop mark-paid/unpaid preview endpoint (frontend computes that preview); split reminder/incasso request DTOs into immutable preview vs execute variants (preview no longer carries includedUserIds/feeTypeOverrides). - Add serverToday to end-membership preview envelope for the FE timezone-safe "started today" boundary; thread a single actionDate through end handlers. - Add NO_EMAIL to BulkRowReason. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…uilder-test assertions
- FeeResolution.kt lived in ..contribution.domain, which matches NO ArchUnit
layer; its original callers (*HandlersKt) were exempted by the layering
rule's handler ignore, but the new callers (EmailPreviewService, the email
builders, EmailSenderService) are not, producing 8 layering violations.
Relocate to ..contribution.domain.service (the Domain layer): Application
and Infrastructure may access Domain, and the file's own imports
(Persistence + Shared) satisfy Domain's constraints. Test moved along.
- ContributionReminderEmailBuilderTest: doesNotContain("website") was too
broad — the correct copy legitimately says the member's role 'on the
website will be revoked'. Assert the actual regression target instead:
no 'via our' / '[website]' pay-link.
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…ing) The preview ITs each took ~400s: the inlining regex [^"'()\s]*suffix backtracks quadratically once the first replacement inserts a ~180KB base64 data URI (one unbroken token the character class happily consumes from every position). That pushed the API IT job past the 30-minute workflow timeout twice. Replace the regex with a linear indexOf + walk-back-to-delimiter splice; behavior identical for src/background attributes and CSS url(...) values. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…de the preview modal - BaseModal gains an #actions-prepend slot; BulkDialogScaffold passes it through as #footer-actions, so the 'Preview email' button now sits in the footer next to Cancel/Send. - The recipient select moves INTO the email-preview modal (top of the pane); changing it re-renders the email for that user. - The date row is back to just the two date fields; the divider below it is removed. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Bring back the subtle surface-tinted, padded title band (bottom divider) and the mirrored actions band (top divider) on BaseModal — removed in the de-outline round, but wanted after seeing the plain version. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Footer order becomes [Cancel] [Preview email] [Send ...]. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The header cell centres its checkbox control (.mm-th-checkbox) while the row cells left-anchored theirs against the td padding, so the boxes did not share a horizontal position. Centre the row checkbox in its 48px cell the same way. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…s from ending
Users with a role above Member cannot have their membership ended. The
end-membership preview EXCLUDES them with specific notes ('It is not
possible to end the membership of a committee member / board member /
administrator / honorary member'; treasurer counts as board), and the
execute handler re-checks roles + honorary membership type against live
data so the protection cannot be bypassed. BulkTarget gains highestRole
(ADMIN > TREASURER > BOARD > COMMITTEE precedence, order-independent).
FE unit + backend IT coverage added.
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…DTOs Add @future to the reminder/incasso execute and preview date fields (paymentDueDate, expectedIncassoDate) and @SiZe(min=1,max=1000) to userIds on all six bulk request DTOs, plus @SiZe(max=1000) on the reminder/incasso includedUserIds set and feeTypeOverrides map. Mirrors the frontend's strictly-future and batch-size rules so a direct API call cannot bypass them. Regenerated openapi.json (adds maxItems: 1000 on the userIds/includedUserIds arrays; @future and Map @SiZe are runtime-only and do not alter the schema). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Add a requireCutoffWithinPeriod guard to the reminder and incasso execute handlers (400 when cutoffDate falls outside the contribution period), mirroring the FE. Inject MembershipService into the mark-paid/ unpaid handler and skip honorary members (most-recent membership), a mirror of the FE's SKIPPED(HONORARY) so a direct call cannot mark an honorary member paid. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
A userId with no user made users.findById throw 404 mid-loop, aborting the entire transaction. Guard every per-user lookup with existsById (end/resume membership handlers, and the contribution reminder/incasso/ mark handlers in the previous commit): treat an unknown id as skipped and continue so one bad id can never poison the batch. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
computeEndMembershipRows now skips an active membership that started today or later (STARTED_TODAY), matching the backend's startDate-before- today predicate; rename its _today param to today. Extend the FE unit tests. Add backend tests: cutoff-outside-period -> 400, honorary skip in mark-paid, poisoned-batch (valid+unknown -> applied=1 skipped=1) for incasso/resume, and a MockMvc validation IT (past date -> 400, >1000 ids -> 400, preview past date -> 400). Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…show-through Replace semi-transparent hover backgrounds with layered gradients that preserve opacity, preventing scrolled table rows from showing through the sticky header when hovered. Affects sortable column headers in bulk action dialogs and the User Manager page. - BulkDialogScaffold: Use gradient-over-surface approach for consistent hover tint - UserManager: Apply same opaque gradient technique to sticky headers Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
- Grayscale font smoothing (-webkit-font-smoothing: antialiased): the default subpixel AA fringes saturated text on dark backgrounds (the red exclusion notes read as blurry/low-resolution). - Register Barlow Semi Condensed with full weight ranges (Light 100-300, Regular 400-500, SemiBold 600-900) so every requested weight maps to a real face and the browser never synthesizes faux-bold. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The new @future validation on paymentDueDate/expectedIncassoDate rejects the ITs' hardcoded past dates (2024-12-31) and the authz tests' today values (validation binds before method security, so 400 preempted the expected 403). Use LocalDate.now().plusDays(30) so the bodies are valid and each test observes its intended status. Validation unchanged. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…rule - The reminder/incasso IT fixtures used a hardcoded 2024 cutoff outside the now-relative fixture period, tripping the new cutoff-within-period guard; use LocalDate.now() (inside the period; all fixture memberships start 2024-01-01, keeping the before-cutoff fee semantics). - Fee boundary divergence: the backend charged HALF_YEAR_FEE for a membership starting exactly ON the cutoff (>=), while the approved rule and the frontend preview say start <= cutoff pays FULL_YEAR_FEE. Align resolveMemberFee/resolveFeeType to strictly-after (>) and pin the boundary in FeeResolutionTest. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…unds messages - Reminder/incasso modals show a one-line period summary under the date row (bounds + full/half/alumni fees, testid bulk-period-info) so the operator sees the window the cutoff must fall within. - The FE cutoff rule and the backend 400 guard now state the actual period bounds instead of a generic/placeholder message. (The within-period validation itself already existed on both sides.) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Replace the plain caption line with small tonal chips (period range with a calendar icon, plus one chip per fee tier), matching the counts-row visual language beneath it. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…by side Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…ropped .text-overline) Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The desktop table rendered every filtered row (~35 Vuetify components each): at 1500 users a search-clear patched ~1.8s of components, which is what made filtering feel slow — matching against the precomputed haystacks costs under 0.1ms and was never the bottleneck. Replace the hand-rolled v-table with v-data-table-virtual: the existing custom header row (sortable ths, select-all, bulk-actions menu) moves into #headers and UserManagerRow into #item, so testids, selection semantics (still driven by filteredRows) and the external filter/sort pipeline are unchanged. Rendered rows are now bounded by the viewport (~17 at height 600 / row 36), striping switches from nth-child to index-based classes (recycled rows keep parity), and fixed-header replaces the manual sticky-thead CSS. With per-keystroke cost now O(visible), the 200ms search debounce is removed: search and searchInput collapse into one ref (both names kept for template/test compatibility). The unit suite runs without the Vuetify plugin, where slot-based components render nothing, so tests/setup.ts registers a slot-rendering VDataTableVirtual stand-in; VirtualizedFilterPerf.test.ts installs real Vuetify to pin the rendered-row bound at N=1500. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Vuetify's virtualizer has a hardcoded 100px buffer and defers window recalculation to requestAnimationFrame, so fast scrolling always outruns the rendered window by a frame — the only lever against visible pop-in is making rows cheap to mount: - Replace all six per-row v-tooltip instances with native title/aria-label attributes; each tooltip mounted overlay + positioning machinery, the bulk of the row's mount cost. - Cap cell content (checkbox, icon buttons) so every row measures exactly the declared 36px item-height: a row measuring taller trips the virtualizer's ResizeObserver into a full offsets recalculation and a visible window jump. - Stop allocating an O(N) users.find closure per row per window shift: row deletion resolves through the existing usersById map. - paidUserIds becomes shallowRef (usePaidToggle always reassigns a fresh Set), removing proxy depth from the row hot path. The system tests assumed every member row exists in the DOM; under virtualization off-viewport rows are not rendered, so row locators timed out. MemberManagerBulkHelper gains scrollRowIntoView (scrolls the table's internal scroller until the row renders) used by selectUserRow / isRowSelected / waitForPaidStatus and the row-body click tests. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Under the browser's automatic table layout, column widths were derived from whichever rows happened to be in the virtual render window, so the header and columns visibly jumped while scrolling. table-layout: fixed pins the layout to the header row's explicit widths instead. Width distribution (verified against long-content fixtures in a rendered replica at 1400px and 1216px table widths): checkbox 48px, Name 19%, Username 15%, Role 8%, Membership status 10%, Member since 10%, Member in period 8%, Paid in period 8%, Type/Incasso 7%, actions 160px. The actions column MUST be 160px: fixed layout enforces the header width, and the four 32px icon buttons were clipped at the previous 64px (auto layout used to stretch that column silently). Cells truncate with an ellipsis instead of growing (also protects the 36px fixed row height the virtualizer depends on), and the Name/Username cells carry title attributes so hover reveals the full value. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The Contribution period and Summary boxes drop their border for the same elevated look as the contribution-periods widget on the manager page: a slightly lifted surface tint plus the elevation-2 drop shadow. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
EmailBulkDecision, requireCutoffWithinPeriod and validateFeeTypeOverrides were duplicated between the reminder and incasso handlers; they move to BulkEmailActionHelpers.kt so the incasso handler no longer depends on a reminder file. decideReminder/decideIncasso stay separate — their decision logic genuinely differs. The two bulk controller ITs were ~95% clones. BulkEmailControllerITBase now owns the shared fixtures, request builders and the MixedCohort and Authorization test nests; the concrete ITs keep their endpoint-specific Execute/Preview nests under distinct names (ReminderExecute etc.) so they cannot shadow the inherited nested classes — identically-named @nested inner classes in a subclass silently replace the base's tests. Net -298 lines with every test name and assertion preserved. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
BaseModal's seven named slots and BulkDialogScaffold's slots (including
the dynamic cell.<key> family, typed with a template-literal index
signature carrying the {row} binding) were documented only in comments.
defineSlots<T>() makes slot names and payloads type-checked: consumers
get autocomplete and a typo'd slot name is a compile error instead of a
silent render no-op. Type-level only — templates unchanged.
Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
isClickOnInteractiveTarget was defined identically in UserManager and UserManagerRow; it moves to utils/rowInteraction.ts as the single definition. UserManagerMobileRow declared toggleDisabled/isSaving props it never used (mobile rows have no paid-toggle button) — removed. Long comments compressed to the two-line rule; the load-bearing 36px virtualizer height contract stays, tightened. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
…driven headers Three consolidations that share page-side wiring in UserManager: useBulkEmailAction extracts the logic the reminder and incasso dialogs duplicated — row computation, cutoff/date rules, fee-type seeding and overrides, period info, preview wiring and submit orchestration — behind a typed BulkEmailActionConfig (concept: a bulk email action over a contribution period with fee resolution). Templates stay per-dialog; they differ enough that sharing them would just add slot indirection. The extraction surfaced a real bug: the scaffold was bound with a string ref, so the form validate() call never reached the component and the tolerant fallback let previews/sends through unvalidated. The composable now takes the dialog's scaffold ref explicitly (callback-ref bound) and fails closed; a regression test pins that an invalid form blocks the execute call. MarkPaidDialog/MarkUnpaidDialog collapse into PaidStatusDialog with a targetState: "paid" | "unpaid" prop and an exhaustively-typed config map — no runtime fallback that could render nothing silently. Both test files' assertion sets are preserved in PaidStatus.test.ts. The ten near-identical sortable <th> blocks in UserManager become one HEADER_COLUMNS config + v-for, keyed by testid; all testids, aria-sort, keyboard handlers and the fixed-layout column widths are unchanged. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The extracted BulkEmailControllerITBase hardcoded incasso = false on its fixture memberships. Reminders don't care, but the incasso handler only applies to members with incasso enabled, so the two inherited shared tests ran against the incasso endpoint with non-incasso members and got applied: 0. The base takes a defaultIncasso constructor parameter used by every shared-test fixture; the incasso IT passes true — restoring what its pre-extraction tests did. This was the one real semantic difference the '95% clone' assessment missed. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
The bulk modal tests each carried their own target()/period() factory plus per-disposition variants. They move to tests/unit/helpers/ bulkFixtures.ts (data builders only — vi.mock blocks stay per-file: vitest's hoisting transform is per test file). Dialog-specific defaults (e.g. incasso-enabled members) are explicit overrides at the call site or a small documented local wrapper, never a competing parallel factory. A planned userFixtures module for the management-page tests ended up with zero consumers and was dropped rather than shipped as dead weight; that dedup wasn't worth the module. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
Fee-type override state was string-keyed with scattered String(userId) coercions even though user IDs are numbers — a footgun for any caller that forgets the coercion. Internal state is now Record<number, FeeType> end-to-end; the single string-key conversion lives at the generated-API boundary in onConfirm, where the JSON wire type demands it (serialized output is byte-identical). The .bulk-date-row/.bulk-feetype-select styles were byte-duplicated in both email dialogs' scoped blocks; they move to styles/bulk-dialogs.scss (loaded via main.scss). The classes are namespaced to these dialogs, so losing the scoped data-v attribute does not widen their reach; the reminder-only .bulk-struck stays scoped in ReminderDialog. Co-Authored-By: Joris Jonkers <info@jorisjonkers.dev>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #420. Reimplements the member-manager bulk actions around the maintainer's brief: per-action modals, client-side preview from existing frontend data where feasible, one submit endpoint per action — while also applying the fix every review lens converged on: preview and execute now share one
decide()function per action so they can no longer diverge.Design doc:
docs/proposals/bulk-actions/REDESIGN.md.Key decisions
mark-paid,mark-unpaid,end-membershipare computed in the browser (all decision inputs already live inuseMemberRows/usePaidToggle).reminder,incasso,resumestay server-preview: the fee tier needs the latest membership start (the FE only derives the earliest),resumeneedsperiods.findLatest()which the FE never loads, andlastSentOn/ email-blank live only server-side. Porting those to JS would generate the very divergence the brief wants gone.decide()per action, shared by preview + execute.decideReminder/decideIncasso(and the existingclassifyUserfor resume) are the single decision path. This — not the endpoint rename — is what resolves the "split logic / dual behaviors" complaint.NO_EMAILis a newBulkRowReasonsurfaced in preview. (Confirmed correctness bug.)includedUserIds/feeTypeOverrideswere sent to preview and ignored; they are removed from all preview requests and live only in FE state, sent solely to execute. FE shows the effective € live via a tiny type→€ helper (not a fee-tier port)./contributions/bulk/mark-paid,…/mark-unpaid,/contributionReminders/bulk/{preview,execute},/incassoNotifications/bulk/{preview,execute},/memberships/bulk/{end,resume}/{preview,execute}). Two controller files kept (not six — they are 3-line dispatchers).serverTodayfor end-membership. The end preview returns the server's date; the FE computes the "started today" boundary against it (notnew Date()) to avoid the browser-vs-server timezone flake already fixed for resume (commit4deb7a13).includedUserIds.What's fully implemented
decide()extraction for reminder + incasso; NO_EMAIL; singleactionDatethreaded through end-membership;serverTodayon the end-preview envelope; override validation guard; endpoint rename + mark-paid/unpaid preview removal; immutable preview request DTOs (split into preview vs execute variants, allval+ jakarta constraints).markPaid/markUnpaidfunctions, split request types,NO_EMAIL,serverToday).MarkPaid,MarkUnpaid,EndMembership,ResumeMembership,Reminder,Incasso) over a sharedBulkDialogScaffold+ action-agnosticuseBulkPreviewcomposable + purebulkDisposition/feePreview/bulkComputehelpers.MemberManagerrenders<component :is=dialogFor[action]>and loses per-action branching. OldBulkActionConfirmDialogmonolith deleted.serverTodayassertion; vitest specs forbulkCompute(incl. serverToday boundary),feePreview, anduseBulkPreview; e2e route mocks + system-test helper updated for the new paths / envelope.Scaffolded / follow-ups
TODOstubs were needed — the full scope is implemented. CI (build + IT + vitest + e2e/system) is the validator, since local builds/test suites OOM the sandbox and were not run here.ContributionReminder-for-period follow-up is noted in the design doc if double-send is ever observed.