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_scopemode; - 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:
- Remove the persistent
전체 대상 선택button from the filter toolbar. - Keep the table-header checkbox as the only initial bulk-selection entry.
- The header checkbox continues to select or clear only the current page.
- If the filtered result fits on one page, header selection already covers the full result; no second select-all action is rendered.
- If the filtered result spans multiple pages, selecting the full current page reveals a contextual selection bar above the table.
- The contextual bar offers an explicit escalation from “current page” to “all matching results.”
- “All matching results” must honor the selected year, team filter, search text, organization, and permission scope.
- Global selection must have strong visible feedback in the contextual bar; it must not rely on a toolbar button color change.
- Changing year, team, or search clears both explicit and global selection immediately.
- No compatibility branch or legacy fallback is introduced.
- 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
| State | Table state | Contextual selection bar | Batch scope |
|---|---|---|---|
| None | No checked rows | Hidden | None |
| Explicit rows | Selected rows checked | Shows selected count only when useful | Explicit employee IDs |
| Current page selected, one page total | All rows and header checked | Hidden | Explicit employee IDs |
| Current page selected, multiple pages | Current page rows and header checked | Offers preflight for all matching eligible results | Explicit employee IDs until confirmed |
| Preflight pending | Selection controls are temporarily disabled | Shows loading state | No batch yet |
| All matching preflight-confirmed | Selection controls are checked and disabled as a global-scope indicator | Shows the exact preflight eligible count and offers clear | Server-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:
현재 페이지 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:
현재 조건에 맞는 발송 대상 63명을 확인했어요. 확인하고 통지 시작확인하고 통지 시작consumes the preflight token and starts the batch.선택 해제clears the global selection without creating a batch.- Counts use
tabular-numsbecause 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
- Keep the existing native
ds-checkboxheader and row controls. - Do not create a second checkbox outside the table.
- The contextual bar is a selection-status/action region, not an informational warning; do not style it as
.ds-notice. - Use design-system text-button and typography tokens. Do not hand-roll colors, borders, shadows, or radius.
- The action must be visually recognizable as interactive and keyboard reachable.
- Do not add animation as a dependency. A direct reactive appearance/disappearance is sufficient.
- Preserve the Table 1 structure and existing pagination pattern.
- 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
explicitmode.
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, andperiodEndfrom 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
annualLeavePromotionSelectAllnaming with state that describes the domain, such asannualLeavePromotionAllMatchingSelected; - 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:batchCreateis 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
employeeIdsvalid only forexplicitmode 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, oremployeeIds.
Task 2 — Propagate typed filters through the SDK
In packages/sdk/src/services/AnnualLeavePromotionService.ts:
- add
searchandteamIdsonly to the typed batch preflight input; - remove
search,teamIds, andemployeeIdsfrom the typed batch-create input; - do not use
Record<string, unknown>, ambient declarations, oras any; - also correct
LeaveRequestService.listBalances()typing to include the already-supportedteamIdsfilter 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.listBalancesso the table and preflight cannot drift; - extract the employee search/team predicate into
packages/api/src/utils/employee-filter.ts, and updatepackages/api/src/routers/leave-request.router.tsand 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
TRPCErrormessages 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:
explicitrequires non-emptyemployeeIdsand ignores/rejects global-only filters consistently;all_eligible_in_scopemust not accept client-suppliedemployeeIds;batchCreateaccepts 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-alltoolbar 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-testidvalues 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
batchCreatewith 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.
- The contextual action or toolbar
일괄 통지opens the configuring phase with the intended selection mode. 통지 시작in the configuring phase runs preflight and changes the modal to confirming.- The confirming phase shows the exact eligible count, the copied filter summary, and the token expiry time or a clear expiry hint.
- The confirming phase renders the global-scope checkbox state when the mode is
all_eligible_in_scope. - The final action is labeled
확인하고 통지 시작and calls batchCreate with the token. - A successful batch creation resets the selection state and closes the confirmation flow.
- A preflight result of zero keeps the modal in configuring/error state and does not create a batch.
- 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.tsapps/admin/src/views/tabs/leave-management.htmlapps/admin/src/app-annual-leave-selection.test.tspackages/core/src/schemas/annual-leave-promotion.schema.tspackages/sdk/src/services/AnnualLeavePromotionService.tspackages/sdk/src/services/LeaveRequestService.tsonly if needed to correct the existingteamIdstypepackages/api/src/routers/annual-leave-promotion.router.tspackages/api/src/routers/leave-request.router.tspackages/api/src/services/annual-leave-promotion.service.tspackages/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:
- No selection hides the contextual bar.
- Partial-page selection does not offer global escalation.
- Selecting a full one-page result does not show a redundant all-results action.
- Selecting a full page in a multi-page result shows the preflight action without claiming the final target count.
- A successful preflight stores the token and exact eligible count, then enters the confirming phase.
- The confirming phase renders global-mode row/header checkboxes as checked and disabled.
- Final confirmation consumes the token and starts the batch, then resets selection on success.
- Clearing the contextual global state clears all selection.
선택 해제clears global mode before row/header controls become available again.- Page navigation preserves explicit cross-page IDs.
- Year, team, and search changes clear every selection mode.
- Preflight captures and sends normalized filters, while batchCreate sends only the token and bound fields.
- 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:
- Global preflight includes all eligible employees when no team/search filter is active.
- Search matches employee number, email, and concatenated full name.
- Team filtering includes only active membership in one of the selected teams.
- Organization filtering cannot be bypassed.
- Permission scope cannot be bypassed.
- Deleted/inactive employees are excluded.
- Employees with no positive remaining balance are excluded.
- Explicit mode remains limited to supplied IDs and scope.
- Global mode rejects client employee IDs.
- The persisted token snapshot contains exactly the resolved filtered IDs.
- 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.
- On initial load, the toolbar has no
전체 대상 선택button. - Selecting one row checks only that row and does not show global escalation.
- Selecting the header checks the current 30 rows.
- A contextual bar appears and offers preflight for all matching eligible results.
- Clicking the preflight action shows a loading state, then the exact eligible count.
- Before final confirmation, row/header checkboxes are checked and disabled, and the bar clearly indicates global selection.
- The final confirmation is a separate action; it creates a batch only after confirmation and then resets selection on success.
- Clearing global selection before final confirmation removes the indication and clears selection without creating a batch.
- With seven results, header selection checks all seven and no redundant action appears.
- Apply a team filter, start preflight, and verify no employee outside that team enters the snapshot.
- Apply a search filter, start preflight, and verify no nonmatching employee enters the snapshot.
- Change year/team/search after preflight and verify the token and selection reset immediately.
- Verify
Tab,Space, visible focus, screen-reader names, and live status announcement. - Verify the confirmation count is lower than the visible row count when some employees have no remaining leave.
12. Verification
Run targeted checks first:
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 buildThe 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.