Skip to content

Annual Leave Contextual Select-All Plan ​

Status: proposed on 2026-08-13 Scope: admin annual-leave status selection and batch-notification target resolution Approval rule: this document does not authorize implementation. Before changing code, the executing Agent must obtain Eron's explicit approval. Commit and push require separate approval.

1. Problem ​

The annual-leave status toolbar currently exposes a persistent 전체 대상 선택 button beside the search field while the table header already contains a select-all checkbox.

The problem is not that two controls exist. The problem is that their scopes and feedback are disconnected:

  • the header checkbox selects the current page;
  • the toolbar button silently switches to all_eligible_in_scope mode;
  • entering that mode clears explicit row IDs, so visible row and header checkboxes remain unchecked;
  • the mode only has a subtle button label/style change and does not affect the screen until the administrator opens the batch-notification flow;
  • the server-wide mode currently ignores the active team and search filters.

As a result, the toolbar control looks broken and can select a broader population than the administrator inferred from the filtered table.

The underlying product need remains valid: administrators must be able to select one row, one page, or every matching employee across all pages. The entry and feedback must make that scope progression explicit.

2. Product Decision ​

These decisions are binding for this task:

  1. Remove the persistent 전체 대상 선택 button from the filter toolbar.
  2. Keep the table-header checkbox as the only initial bulk-selection entry.
  3. The header checkbox continues to select or clear only the current page.
  4. If the filtered result fits on one page, header selection already covers the full result; no second select-all action is rendered.
  5. If the filtered result spans multiple pages, selecting the full current page reveals a contextual selection bar above the table.
  6. The contextual bar offers an explicit escalation from “current page” to “all matching results.”
  7. “All matching results” must honor the selected year, team filter, search text, organization, and permission scope.
  8. Global selection must have strong visible feedback in the contextual bar; it must not rely on a toolbar button color change.
  9. Changing year, team, or search clears both explicit and global selection immediately.
  10. No compatibility branch or legacy fallback is introduced.
  11. The UI never presents the filtered-row count as the final notification count. A server preflight resolves the exact eligible count before the final confirmation.

This plan supersedes the global-toolbar behavior defined in 2026-08-06-annual-leave-page-selection.md, specifically Product Behavior items 7 and 9, Task 4, Manual QA items 12–14, and the related acceptance criterion. The implemented current-page checkbox behavior remains valid.

3. Interaction Model ​

3.1 Selection states ​

StateTable stateContextual selection barBatch scope
NoneNo checked rowsHiddenNone
Explicit rowsSelected rows checkedShows selected count only when usefulExplicit employee IDs
Current page selected, one page totalAll rows and header checkedHiddenExplicit employee IDs
Current page selected, multiple pagesCurrent page rows and header checkedOffers preflight for all matching eligible resultsExplicit employee IDs until confirmed
Preflight pendingSelection controls are temporarily disabledShows loading stateNo batch yet
All matching preflight-confirmedSelection controls are checked and disabled as a global-scope indicatorShows the exact preflight eligible count and offers clearServer-resolved filtered scope

3.2 Required Korean copy ​

When the current page is fully selected and more matching pages exist, do not claim the final notification count before preflight:

text
현재 페이지 30명을 선택했어요. 조건에 맞는 대상 모두 확인
  • 현재 페이지 30명을 선택했어요. is status text.
  • 조건에 맞는 대상 모두 확인 starts the server preflight; it is not the final send action.
  • The visible filtered-row total may be shown separately, but it must not be described as the notification target count.

After preflight resolves the exact eligible target set, the modal enters its confirmation phase:

text
현재 조건에 맞는 발송 대상 63명을 확인했어요. 확인하고 통지 시작
  • 확인하고 통지 시작 consumes the preflight token and starts the batch.
  • 선택 해제 clears the global selection without creating a batch.
  • Counts use tabular-nums because they update with filters and selection state.
  • The bar uses role="status" or an equivalent polite live region so keyboard and screen-reader users receive the state change.
  • If preflight returns zero eligible targets, remain in the explicit selection state and show the existing Korean empty-target feedback; do not enter global mode.

3.3 Global-mode row interaction ​

Global mode does not introduce exclusion semantics.

  • The contextual bar is the authoritative representation of global selection.
  • 선택 해제 exits global mode and clears all selection.
  • After a successful preflight and while the confirmation phase is open, row and header checkboxes are checked and disabled as a global-scope indicator. They do not claim that every visible row independently passed the eligibility check; the exact target set is the server-side preflight snapshot.
  • If the administrator wants explicit row selection after global mode, they must click 선택 해제 first; row and header controls cannot silently create an exclusion list.
  • Do not add “all except these employees” behavior. That would require a new exclusion model and is outside this task.

4. UX and Design-System Rules ​

  1. Keep the existing native ds-checkbox header and row controls.
  2. Do not create a second checkbox outside the table.
  3. The contextual bar is a selection-status/action region, not an informational warning; do not style it as .ds-notice.
  4. Use design-system text-button and typography tokens. Do not hand-roll colors, borders, shadows, or radius.
  5. The action must be visually recognizable as interactive and keyboard reachable.
  6. Do not add animation as a dependency. A direct reactive appearance/disappearance is sufficient.
  7. Preserve the Table 1 structure and existing pagination pattern.
  8. The persistent actions 일괄 통지 and 발송 내역 remain in the toolbar.

5. Selection Semantics ​

5.1 Current page ​

The existing explicit selection behavior remains:

  • row checkbox: one employee;
  • header checkbox: the current client-side page;
  • selections from other pages remain selected until the filter context changes;
  • explicit IDs are submitted in explicit mode.

5.2 All matching results ​

all_eligible_in_scope remains the server-side selection mode. Its candidate query receives the active UI filters during preflight only:

  • periodKey, periodStart, and periodEnd from the selected year;
  • normalized search text;
  • selected team IDs;
  • organization ID from authenticated context;
  • employee permission scope from scopedProcedure;
  • active, non-deleted employees only;
  • positive remaining annual-leave balance at the selected period end.

The server remains authoritative. The browser must not send every loaded employee ID to emulate global selection.

The preflight response must include the opaque selection token, expiry, and exact eligible count. The browser stores that token in the modal state and displays the count before the administrator confirms the send.

5.3 Stable batch target ​

The preflight step continues to persist an exact employee-ID snapshot behind the 15-minute selection token. Batch creation consumes that snapshot, so later team membership or search-result changes cannot silently alter an already confirmed target set.

No database migration is required because the snapshot already stores the exact resolved employee IDs, period key, actor, selection mode, and expiry.

The batch-create request consumes only the preflight snapshot. It must not repeat search, teamIds, or employeeIds, which would create a second source of truth after the token has been issued.

6. State Changes ​

In apps/admin/src/app.ts:

  • replace the toolbar-oriented annualLeavePromotionSelectAll naming with state that describes the domain, such as annualLeavePromotionAllMatchingSelected;
  • remove selectAllAnnualLeavePromotionTargets() as a free-standing toolbar toggle;
  • add a contextual preflight method, such as preflightAllMatchingAnnualLeavePromotionTargets();
  • add helpers for:
    • current-page selection count;
    • whether the filtered result spans multiple pages;
    • whether the full current page is selected;
    • contextual bar visibility;
    • contextual bar count/copy;
  • preserve resetAnnualLeavePromotionSelection() as the single reset path;
  • ensure year, team, and search reloads synchronously clear selection before awaiting data;
  • keep Alpine state replacement-based rather than mutating arrays in place.

The modal state must also represent the two-stage batch flow:

  • configuring: template and response duration can be edited;
  • preflighting: the server is resolving the exact target snapshot;
  • confirming: the exact eligible count and token are visible, and the administrator can confirm;
  • submitting: batchCreate is consuming the token;
  • error: the token or preflight failed and the administrator must preflight again.

The filter snapshot used by preflight is copied when the preflight starts. If year, team, or search changes before final confirmation, clear the selection/token and require a new preflight.

The exact method names may follow adjacent project conventions, but the state must describe selection scope rather than UI location.

7. API and Service Changes ​

Task 1 — Separate preflight and batch-create validation ​

In packages/core/src/schemas/annual-leave-promotion.schema.ts:

  • add optional normalized filter fields to batch preflight input only:
    • search: trimmed string with an explicit maximum length;
    • teamIds: unique UUID array with an explicit maximum length;
  • reuse a shared employee-filter schema if one already exists; do not duplicate validation;
  • keep employeeIds valid only for explicit mode at the router/service boundary.
  • define a separate batch-create schema that accepts the selection token, selection mode, period, template, response duration, and operation key, but no search, teamIds, or employeeIds.

Task 2 — Propagate typed filters through the SDK ​

In packages/sdk/src/services/AnnualLeavePromotionService.ts:

  • add search and teamIds only to the typed batch preflight input;
  • remove search, teamIds, and employeeIds from the typed batch-create input;
  • do not use Record<string, unknown>, ambient declarations, or as any;
  • also correct LeaveRequestService.listBalances() typing to include the already-supported teamIds filter if the implementation touches that call path.

Task 3 — Resolve filtered global candidates on the server ​

In packages/api/src/services/annual-leave-promotion.service.ts:

  • apply organization, active status, soft-delete, permission scope, search, and team filters before balance evaluation;
  • preserve the existing positive-balance eligibility check;
  • reuse or extract the employee search/team predicate used by leaveRequest.listBalances so the table and preflight cannot drift;
  • extract the employee search/team predicate into packages/api/src/utils/employee-filter.ts, and update packages/api/src/routers/leave-request.router.ts and this preflight service to consume the same helper;
  • never trust organization or scope data from the client;
  • keep exact employee IDs in the existing selection snapshot;
  • keep typed Korean TRPCError messages and structured logger usage.

Search must follow the table's existing contract: employee number, email, and concatenated Korean full name. Team matching must use active team membership.

Task 4 — Validate mode-specific input ​

At the router/service boundary:

  • explicit requires non-empty employeeIds and ignores/rejects global-only filters consistently;
  • all_eligible_in_scope must not accept client-supplied employeeIds;
  • batchCreate accepts only the preflight selection token and its bound mode/period; it does not re-evaluate client filters;
  • batch creation must still validate actor, organization, period, mode, token expiry, and token consumption through the snapshot.

Prefer a schema refinement or discriminated union if it keeps the SDK input practical and avoids duplicated DTOs.

8. Frontend Changes ​

Task 5 — Remove the persistent toolbar action ​

In apps/admin/src/views/tabs/leave-management.html:

  • remove the annual-leave-promotion-select-all toolbar button;
  • keep search, 일괄 통지, and 발송 내역 aligned in the existing action area;
  • do not leave an empty gap or replacement placeholder.

Task 6 — Add the contextual selection bar ​

Render it between the filter/summary area and the table:

  • hidden when nothing is selected;
  • hidden for one-page full selection because the header already selected the whole result;
  • visible after the current page is fully selected when multiple pages exist;
  • the preflight action opens the batch modal in global-selection configuration mode;
  • after the modal starts preflight, it displays the exact eligible count returned by the server;
  • contains one clear, text-style action at a time;
  • uses Korean copy from Section 3.2;
  • has stable data-testid values for status, escalation, and clear actions.

Task 7 — Keep checkbox states understandable ​

  • explicit mode continues to derive row/header checked state from explicit IDs;
  • global mode renders row/header checkboxes as checked and disabled as a global-scope indicator;
  • global mode is cleared only through the contextual 선택 해제 action before explicit row/header selection can resume;
  • this behavior must be consistent across row rendering, header rendering, page navigation, and filter changes;
  • row/header behavior in global mode must be covered by unit tests.

Task 8 — Submit the active filter snapshot ​

When opening/submitting the batch flow in global mode:

  • copy normalized year/search/team filter values into the modal or request state;
  • do not read mutable live filters after preflight has started;
  • send the copied filter values to preflight only;
  • store selectionToken, expiresAt, and the preflight-resolved eligible count in modal state;
  • keep the modal open and require a second explicit confirmation after the exact count is shown;
  • call batchCreate with the stored token and bound mode/period, without repeating filters or employee IDs;
  • if the token expires or the filter changes before confirmation, discard the token, reset selection, and require a new preflight.

Task 9 — Implement the two-stage confirmation flow ​

The batch modal must not call preflight and batchCreate back-to-back behind one button click.

  1. The contextual action or toolbar 일괄 통지 opens the configuring phase with the intended selection mode.
  2. 통지 시작 in the configuring phase runs preflight and changes the modal to confirming.
  3. The confirming phase shows the exact eligible count, the copied filter summary, and the token expiry time or a clear expiry hint.
  4. The confirming phase renders the global-scope checkbox state when the mode is all_eligible_in_scope.
  5. The final action is labeled 확인하고 통지 시작 and calls batchCreate with the token.
  6. A successful batch creation resets the selection state and closes the confirmation flow.
  7. A preflight result of zero keeps the modal in configuring/error state and does not create a batch.
  8. A token-expiry or stale-filter error returns the modal to configuring and requires preflight again.

Apply the same two-stage behavior to explicit multi-employee selection so the displayed count always reflects the actual eligible target set.

9. Files ​

Modify ​

  • apps/admin/src/app.ts
  • apps/admin/src/views/tabs/leave-management.html
  • apps/admin/src/app-annual-leave-selection.test.ts
  • packages/core/src/schemas/annual-leave-promotion.schema.ts
  • packages/sdk/src/services/AnnualLeavePromotionService.ts
  • packages/sdk/src/services/LeaveRequestService.ts only if needed to correct the existing teamIds type
  • packages/api/src/routers/annual-leave-promotion.router.ts
  • packages/api/src/routers/leave-request.router.ts
  • packages/api/src/services/annual-leave-promotion.service.ts
  • packages/api/src/services/annual-leave-promotion.service.test.ts

Add ​

  • packages/api/src/utils/employee-filter.ts

Do not modify ​

  • database entities or migrations unless implementation proves the existing exact-ID snapshot is insufficient;
  • unrelated annual-leave promotion workflow, Sign, message, or batch-worker behavior;
  • shared design-system CSS for a page-specific selection bar.

10. Automated Tests ​

Frontend unit tests ​

Update apps/admin/src/app-annual-leave-selection.test.ts to cover:

  1. No selection hides the contextual bar.
  2. Partial-page selection does not offer global escalation.
  3. Selecting a full one-page result does not show a redundant all-results action.
  4. Selecting a full page in a multi-page result shows the preflight action without claiming the final target count.
  5. A successful preflight stores the token and exact eligible count, then enters the confirming phase.
  6. The confirming phase renders global-mode row/header checkboxes as checked and disabled.
  7. Final confirmation consumes the token and starts the batch, then resets selection on success.
  8. Clearing the contextual global state clears all selection.
  9. 선택 해제 clears global mode before row/header controls become available again.
  10. Page navigation preserves explicit cross-page IDs.
  11. Year, team, and search changes clear every selection mode.
  12. Preflight captures and sends normalized filters, while batchCreate sends only the token and bound fields.
  13. The old free-standing toolbar-toggle expectations are removed.

Test selection behavior and payloads, not Alpine markup implementation details.

API/service tests ​

Add or update tests proving:

  1. Global preflight includes all eligible employees when no team/search filter is active.
  2. Search matches employee number, email, and concatenated full name.
  3. Team filtering includes only active membership in one of the selected teams.
  4. Organization filtering cannot be bypassed.
  5. Permission scope cannot be bypassed.
  6. Deleted/inactive employees are excluded.
  7. Employees with no positive remaining balance are excluded.
  8. Explicit mode remains limited to supplied IDs and scope.
  9. Global mode rejects client employee IDs.
  10. The persisted token snapshot contains exactly the resolved filtered IDs.
  11. Batch creation still rejects expired, consumed, wrong-actor, wrong-period, or wrong-mode tokens.

11. Manual QA ​

Use at least 31 filtered employees so two pages exist.

  1. On initial load, the toolbar has no 전체 대상 선택 button.
  2. Selecting one row checks only that row and does not show global escalation.
  3. Selecting the header checks the current 30 rows.
  4. A contextual bar appears and offers preflight for all matching eligible results.
  5. Clicking the preflight action shows a loading state, then the exact eligible count.
  6. Before final confirmation, row/header checkboxes are checked and disabled, and the bar clearly indicates global selection.
  7. The final confirmation is a separate action; it creates a batch only after confirmation and then resets selection on success.
  8. Clearing global selection before final confirmation removes the indication and clears selection without creating a batch.
  9. With seven results, header selection checks all seven and no redundant action appears.
  10. Apply a team filter, start preflight, and verify no employee outside that team enters the snapshot.
  11. Apply a search filter, start preflight, and verify no nonmatching employee enters the snapshot.
  12. Change year/team/search after preflight and verify the token and selection reset immediately.
  13. Verify Tab, Space, visible focus, screen-reader names, and live status announcement.
  14. Verify the confirmation count is lower than the visible row count when some employees have no remaining leave.

12. Verification ​

Run targeted checks first:

bash
pnpm --filter @hereby/admin exec vitest run src/app-annual-leave-selection.test.ts
pnpm --filter @hereby/api exec vitest run src/services/annual-leave-promotion.service.test.ts
pnpm --filter @hereby/admin build
pnpm --filter @hereby/api build
pnpm --filter @hereby/sdk build
pnpm check:icons
pnpm check:dialogs
pnpm lint:check
pnpm build

The current working tree already contains unrelated inline-SVG gate failures in leave-management.html and messenger-content.html. Before implementation completion, resolve or separately account for that baseline; do not misreport it as caused by the contextual select-all change.

13. Acceptance Criteria ​

  • The persistent 전체 대상 선택 toolbar button is removed.
  • The header checkbox remains the only initial bulk-selection entry.
  • One-page results do not show a redundant all-results action.
  • Multi-page current-page selection exposes a contextual all-matching action.
  • Global selection has immediate, prominent, accessible feedback after preflight resolves the exact eligible count.
  • Global selection honors year, team, search, organization, permission, active/soft-delete, and positive-balance constraints.
  • Preflight persists an exact, expiring target snapshot.
  • The modal requires an explicit confirmation after preflight and never creates a batch from the same click that starts preflight.
  • Batch creation consumes only the validated selection token and does not repeat mutable filters or employee IDs.
  • Filter changes clear selection before reloading.
  • No exclusion model, compatibility branch, custom design-system CSS, native dialog, or database migration is introduced.
  • Targeted unit tests, affected package builds, repository lint, and repository build pass, or unrelated pre-existing failures are documented with evidence.