Annual Leave Table — Current-Page Selection Plan
Status: implemented on 2026-08-06 Scope: admin annual-leave status table only Approval rule: before changing code, the executing Agent must obtain Eron's explicit approval. Commit and push require separate approval.
1. Problem
The annual-leave status table has row checkboxes, but its selection-column header is empty. Administrators must select or clear employees one row at a time.
The core problem is not styling. The table is missing the standard bulk-selection state machine:
- none selected;
- some rows selected (
indeterminate); - every row on the current page selected.
A paginated table should expose this state through one native header checkbox. This is the established web-table pattern because it is keyboard accessible, screen-reader friendly, and keeps selection scope visible.
2. Confirmed Product Behavior
These decisions are binding for this task:
- The header checkbox acts on the current page after the active filters are applied.
- Clicking an unchecked or indeterminate header checkbox selects every row on the current page.
- Clicking a checked header checkbox clears every selected row on the current page.
- Selection from other pages is preserved while paging within the same filter context.
- Changing year, affiliation/team, or search text clears all row selection and clears the global-all mode. Hidden employees must never remain silently selected after a filter change.
- An empty page renders a disabled, unchecked header checkbox.
- The existing
전체 대상 선택action remains a separate concept: it means all server-eligible employees in the selected period and authenticated organization/permission scope. It is not redefined as current-page or filtered-page selection. - Starting row or header selection while
annualLeavePromotionSelectAllis active exits global-all mode and switches to explicit employee IDs. - Global-all mode has its own visible state: the toolbar action exposes
aria-pressed="true", an active design-system button treatment, and the label전체 대상 선택 해제. The table header and row checkboxes continue to represent only explicit current-page IDs; they must not pretend that every visible row is selected when the server-side eligible scope is broader than the loaded page.
Selection scopes
| Interaction | Scope |
|---|---|
| Row checkbox | One employee |
| Header checkbox | Current filtered page |
전체 대상 선택 button | All server-eligible employees in organization/permission scope for the period |
Do not merge these scopes or change the API selection modes.
3. In Scope
- Add a native tri-state checkbox to the first table header cell.
- Apply the design-system checkbox class to every native checkbox in the touched template, including header, row, filter, and plan-item controls.
- Add current-page selection state and toggle methods.
- Reset selection when filter inputs change.
- Add focused unit tests for the selection behavior.
- Run targeted and repository verification.
4. Out of Scope
- Backend, SDK, schema, database, or API changes.
- Changing
EXPLICIT_IDS/ALL_ELIGIBLE_IN_SCOPEsemantics. - Adding exclusion lists to global-all mode.
- Selecting every page through the header checkbox.
- Redesigning the annual-leave toolbar, modal, table, or pagination beyond the existing global-all toggle state.
- Compatibility code or legacy fallback behavior.
5. Files
Modify
apps/admin/src/app.tsapps/admin/src/views/tabs/leave-management.html
Add
apps/admin/src/app-annual-leave-selection.test.ts
Important working-tree warning
As of 2026-08-06, both target implementation files already contain uncommitted annual-leave-promotion work. Before editing:
- Run
git status --short. - Inspect the existing diffs for both target files.
- Make surgical edits around the current selection code only.
- Do not reformat, replace, or revert unrelated changes.
6. Implementation Plan
Task 1 — Add current-page selection methods
In apps/admin/src/app.ts, add methods beside the existing annual-leave promotion selection methods.
Recommended interface:
annualLeavePromotionPageEmployeeIds(): string[]
annualLeavePromotionPageSelectionState(): "none" | "some" | "all"
toggleAnnualLeavePromotionPageSelection(checked: boolean): void
resetAnnualLeavePromotionSelection(): void
reloadLeaveBalancesForFilter(): Promise<void>Behavior:
annualLeavePromotionPageEmployeeIds()derives IDs only fromleaveBalancesPageItems().- Ignore missing/empty employee IDs.
annualLeavePromotionPageSelectionState()compares current-page IDs withannualLeavePromotionSelectedIds:- no page IDs or zero matches →
none; - every current-page ID matched →
all; - otherwise →
some.
- no page IDs or zero matches →
toggleAnnualLeavePromotionPageSelection(true):- sets
annualLeavePromotionSelectAll = false; - merges current-page IDs into
annualLeavePromotionSelectedIds; - preserves selected IDs from other pages;
- avoids duplicates.
- sets
toggleAnnualLeavePromotionPageSelection(false):- sets
annualLeavePromotionSelectAll = false; - removes current-page IDs only;
- preserves selected IDs from other pages.
- sets
resetAnnualLeavePromotionSelection()clears both:annualLeavePromotionSelectedIds = [];annualLeavePromotionSelectAll = false.
reloadLeaveBalancesForFilter()resets selection synchronously, then delegates toloadLeaveBalances().
Use Set for merge/removal logic. Do not mutate the existing array in place; assign a new array so Alpine reactivity remains reliable.
Task 2 — Route filter changes through the reset wrapper
In apps/admin/src/views/tabs/leave-management.html, replace direct filter-triggered calls to loadLeaveBalances() with reloadLeaveBalancesForFilter() for:
- year change;
- affiliation/team selection change;
- affiliation/team clear;
- debounced search input.
Do not replace non-filter refresh calls elsewhere in app.ts. This task only guarantees reset when the filter context changes.
Task 3 — Render the tri-state header checkbox
Replace the empty selection header content with a native checkbox:
- class:
ds-checkbox; - checked when state is
all; indeterminateDOM property when state issome;- disabled when the current page has no employee IDs;
- change handler calls
toggleAnnualLeavePromotionPageSelection($el.checked); - Korean accessible label describing current-page select/clear behavior.
Implementation note: indeterminate is a DOM property, not a persistent HTML attribute. Set it through Alpine x-effect (or another property-safe Alpine binding), not a static attribute.
Expected interaction:
| Before click | Browser action | Result |
|---|---|---|
none | Check | Select current page |
some | Check | Select remaining rows on current page |
all | Uncheck | Clear current page |
Add class="ds-checkbox" to the existing row checkboxes and any other native checkbox in this template that is touched by the change. Do not draw a custom checkbox and do not add custom checkbox CSS.
Task 4 — Preserve existing individual/global behavior
Keep toggleAnnualLeavePromotionSelection(employeeId) as the row-selection entry point and retain its existing rule that row interaction disables global-all mode.
Change selectAllAnnualLeavePromotionTargets() into a global-all toggle: entering global-all clears explicit IDs and sets the flag; clicking it again clears the flag. Update the existing toolbar button to reflect this state with aria-pressed, an active design-system variant, and the labels 전체 대상 선택 / 전체 대상 선택 해제. This is a presentation/toggle change only; the server selection mode remains all_eligible_in_scope.
While global-all mode is active, keep the header and row checkboxes driven by explicit IDs. Their unchecked appearance is intentional and must be explained by the active toolbar state, not replaced with a misleading checked state.
Do not change:
selectAllAnnualLeavePromotionTargets()server-scope meaning;- modal
selectionModevalues; - batch preflight/create payloads;
- selected-period or organization permission logic.
If implementation reveals that global-all mode needs exclusions, stop and ask Eron. That is a backend/product expansion and is not authorized by this plan.
7. Unit Tests
Create apps/admin/src/app-annual-leave-selection.test.ts using createAdminApp() from apps/admin/src/test-support/harness.ts.
Required cases:
- Empty page reports
noneand produces no selected IDs. - One selected row on a multi-row page reports
some. - Every selected row on the page reports
all. - Selecting the header adds every current-page ID without duplicates.
- Selecting another page preserves IDs selected on the first page.
- Clearing the header removes only current-page IDs and preserves other-page IDs.
- Header selection exits
annualLeavePromotionSelectAllmode. - Row selection exits
annualLeavePromotionSelectAllmode and keeps that employee as an explicit selection. - The global-all toolbar action toggles on and off without changing explicit IDs after entering global-all.
- Reset clears explicit IDs and global-all mode.
- Filter reload resets selection before delegating to
loadLeaveBalances(); mock the delegate to avoid network access. - Missing employee IDs are ignored.
Test behavior, not Alpine markup internals.
8. Manual QA
Verify in the admin annual-leave status screen:
- Initial header checkbox is unchecked.
- Checking it selects all visible rows on the current page.
- Clearing one row makes the header indeterminate.
- Clicking the indeterminate header selects the whole current page again.
- Unchecking the fully checked header clears the current page.
- Select page 1, move to page 2, select rows, then return to page 1; page 1 selection remains.
- Clearing page 1 does not clear selected rows on page 2.
- Changing year clears all selections.
- Changing affiliation/team clears all selections.
- Changing search text clears all selections after the debounced filter event.
- A zero-result filter leaves the header disabled and unchecked.
전체 대상 선택enters global-all mode, shows the active toolbar state, and opens the batch flow inall_eligible_in_scopemode.- Clicking
전체 대상 선택 해제clears global-all mode. - After global-all mode, selecting a row or current page switches back to explicit selection and the toolbar returns to its default state.
Check mouse, keyboard (Tab and Space), checked state, and indeterminate rendering.
9. Verification Commands
Run in this order:
pnpm --filter @hereby/admin exec vitest run src/app-annual-leave-selection.test.ts
pnpm --filter @hereby/admin build
pnpm lint:check
pnpm buildIf unrelated existing work causes a full-repository failure, record the exact command and failure, then prove whether the failure is related to this change. Do not modify unrelated code to make the check pass.
10. Acceptance Criteria
- The first header cell contains a visible native
ds-checkbox. - It correctly renders unchecked, indeterminate, and checked states.
- It selects or clears only the current filtered page.
- Cross-page explicit selections persist until filters change or the user clears those pages.
- Year, team, and search changes clear both explicit and global selection state.
- Global-all mode is visibly represented by the toolbar action; table checkboxes remain scoped to explicit current-page selection.
- Existing global-all backend semantics remain unchanged.
- No custom colors, checkbox CSS, native dialogs, API changes, or compatibility branches are introduced.
- Required unit tests pass.
- Admin build passes.
- Repository lint/build pass, or unrelated pre-existing failures are documented with evidence.
11. Implementation Record
Implemented in:
apps/admin/src/app.tsapps/admin/src/views/tabs/leave-management.htmlapps/admin/src/app-annual-leave-selection.test.ts
Delivered behavior:
- Current-page header checkbox supports unchecked, indeterminate, checked, select, and clear states.
- Explicit selections persist across pages within the same filter context.
- Year, team, and search filter reloads clear explicit and global-all selection state.
- Global-all mode is a visible toolbar toggle with
aria-pressedand전체 대상 선택/전체 대상 선택 해제labels. - Every native checkbox in the touched template uses
ds-checkbox.
Verification:
- Targeted Vitest: passed, 10 tests.
- New test file Biome lint: passed.
pnpm --filter @hereby/admin build: passed.pnpm build: passed, 22 tasks successful.- Full
pnpm lint:check: remains blocked by pre-existing diagnostics inapps/employee-appstyles and repository utility scripts. - Admin package lint also reports pre-existing annual-promotion diagnostics in
app.tsand existingmain.cssrules; no diagnostics were emitted for the new test file.