Skip to content

Bulk actions redesign: per-action modals + endpoints (supersedes #420)#492

Draft
jorisjonkers-dev-agents[bot] wants to merge 123 commits into
mainfrom
feat/bulk-actions-redesign
Draft

Bulk actions redesign: per-action modals + endpoints (supersedes #420)#492
jorisjonkers-dev-agents[bot] wants to merge 123 commits into
mainfrom
feat/bulk-actions-redesign

Conversation

@jorisjonkers-dev-agents

@jorisjonkers-dev-agents jorisjonkers-dev-agents Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

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

  • Tiered preview. mark-paid, mark-unpaid, end-membership are computed in the browser (all decision inputs already live in useMemberRows / usePaidToggle). reminder, incasso, resume stay server-preview: the fee tier needs the latest membership start (the FE only derives the earliest), resume needs periods.findLatest() which the FE never loads, and lastSentOn / email-blank live only server-side. Porting those to JS would generate the very divergence the brief wants gone.
  • One decide() per action, shared by preview + execute. decideReminder / decideIncasso (and the existing classifyUser for resume) are the single decision path. This — not the endpoint rename — is what resolves the "split logic / dual behaviors" complaint.
  • NO_EMAIL is now first-class. The reminder/incasso execute previously skipped blank-email users silently while preview didn't; NO_EMAIL is a new BulkRowReason surfaced in preview. (Confirmed correctness bug.)
  • Preview is immutable server truth. includedUserIds / feeTypeOverrides were 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).
  • One submit endpoint per action, action-named paths (/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).
  • serverToday for end-membership. The end preview returns the server's date; the FE computes the "started today" boundary against it (not new Date()) to avoid the browser-vs-server timezone flake already fixed for resume (commit 4deb7a13).
  • Fee-override validation on execute → 400 for EXCLUDED/HONORARY users and for users not in includedUserIds.
  • No dual-stack / versioned / feature-flagged rollout: a single behavior-preserving internal PR with invariant tests.

What's fully implemented

  • Backend: decide() extraction for reminder + incasso; NO_EMAIL; single actionDate threaded through end-membership; serverToday on the end-preview envelope; override validation guard; endpoint rename + mark-paid/unpaid preview removal; immutable preview request DTOs (split into preview vs execute variants, all val + jakarta constraints).
  • OpenAPI: regenerated spec + Blueshell client (DB-free H2). Client diffs produced (markPaid/markUnpaid functions, split request types, NO_EMAIL, serverToday).
  • Frontend: all six per-action dialogs (MarkPaid, MarkUnpaid, EndMembership, ResumeMembership, Reminder, Incasso) over a shared BulkDialogScaffold + action-agnostic useBulkPreview composable + pure bulkDisposition / feePreview / bulkCompute helpers. MemberManager renders <component :is=dialogFor[action]> and loses per-action branching. Old BulkActionConfirmDialog monolith deleted.
  • Tests: decision unit tests (NO_EMAIL, cutoff boundary, HONORARY, override 400); preview==execute invariant ITs for reminder + resume; mark-paid/unpaid ITs on the new paths; end-preview serverToday assertion; vitest specs for bulkCompute (incl. serverToday boundary), feePreview, and useBulkPreview; e2e route mocks + system-test helper updated for the new paths / envelope.

Scaffolded / follow-ups

  • Everything committed compiles-consistently; no TODO stubs 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.
  • Idempotency of email actions on network retry is unchanged (not worsened); a dedupe-by-ContributionReminder-for-period follow-up is noted in the design doc if double-send is ever observed.

@jorisjonkers-dev-agents
jorisjonkers-dev-agents Bot force-pushed the feat/bulk-actions-redesign branch from dcec648 to ceb5535 Compare July 18, 2026 11:49
ExtraToast and others added 29 commits July 20, 2026 07:15
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>
…tion

Reconciles #420 with main: the regenerated spec/client carry both the bulk-actions
endpoints/enums (reason/action/fee-type, resume, memberSince) and main's @Schema
enum refs (#455).

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>
ExtraToast and others added 30 commits July 20, 2026 15:14
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants