Skip to content

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:

  1. The header checkbox acts on the current page after the active filters are applied.
  2. Clicking an unchecked or indeterminate header checkbox selects every row on the current page.
  3. Clicking a checked header checkbox clears every selected row on the current page.
  4. Selection from other pages is preserved while paging within the same filter context.
  5. 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.
  6. An empty page renders a disabled, unchecked header checkbox.
  7. 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.
  8. Starting row or header selection while annualLeavePromotionSelectAll is active exits global-all mode and switches to explicit employee IDs.
  9. 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 ​

InteractionScope
Row checkboxOne employee
Header checkboxCurrent filtered page
전체 대상 선택 buttonAll 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_SCOPE semantics.
  • 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.ts
  • apps/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:

  1. Run git status --short.
  2. Inspect the existing diffs for both target files.
  3. Make surgical edits around the current selection code only.
  4. 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:

ts
annualLeavePromotionPageEmployeeIds(): string[]
annualLeavePromotionPageSelectionState(): "none" | "some" | "all"
toggleAnnualLeavePromotionPageSelection(checked: boolean): void
resetAnnualLeavePromotionSelection(): void
reloadLeaveBalancesForFilter(): Promise<void>

Behavior:

  • annualLeavePromotionPageEmployeeIds() derives IDs only from leaveBalancesPageItems().
  • Ignore missing/empty employee IDs.
  • annualLeavePromotionPageSelectionState() compares current-page IDs with annualLeavePromotionSelectedIds:
    • no page IDs or zero matches → none;
    • every current-page ID matched → all;
    • otherwise → some.
  • toggleAnnualLeavePromotionPageSelection(true):
    • sets annualLeavePromotionSelectAll = false;
    • merges current-page IDs into annualLeavePromotionSelectedIds;
    • preserves selected IDs from other pages;
    • avoids duplicates.
  • toggleAnnualLeavePromotionPageSelection(false):
    • sets annualLeavePromotionSelectAll = false;
    • removes current-page IDs only;
    • preserves selected IDs from other pages.
  • resetAnnualLeavePromotionSelection() clears both:
    • annualLeavePromotionSelectedIds = [];
    • annualLeavePromotionSelectAll = false.
  • reloadLeaveBalancesForFilter() resets selection synchronously, then delegates to loadLeaveBalances().

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;
  • indeterminate DOM property when state is some;
  • 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 clickBrowser actionResult
noneCheckSelect current page
someCheckSelect remaining rows on current page
allUncheckClear 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 selectionMode values;
  • 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:

  1. Empty page reports none and produces no selected IDs.
  2. One selected row on a multi-row page reports some.
  3. Every selected row on the page reports all.
  4. Selecting the header adds every current-page ID without duplicates.
  5. Selecting another page preserves IDs selected on the first page.
  6. Clearing the header removes only current-page IDs and preserves other-page IDs.
  7. Header selection exits annualLeavePromotionSelectAll mode.
  8. Row selection exits annualLeavePromotionSelectAll mode and keeps that employee as an explicit selection.
  9. The global-all toolbar action toggles on and off without changing explicit IDs after entering global-all.
  10. Reset clears explicit IDs and global-all mode.
  11. Filter reload resets selection before delegating to loadLeaveBalances(); mock the delegate to avoid network access.
  12. Missing employee IDs are ignored.

Test behavior, not Alpine markup internals.

8. Manual QA ​

Verify in the admin annual-leave status screen:

  1. Initial header checkbox is unchecked.
  2. Checking it selects all visible rows on the current page.
  3. Clearing one row makes the header indeterminate.
  4. Clicking the indeterminate header selects the whole current page again.
  5. Unchecking the fully checked header clears the current page.
  6. Select page 1, move to page 2, select rows, then return to page 1; page 1 selection remains.
  7. Clearing page 1 does not clear selected rows on page 2.
  8. Changing year clears all selections.
  9. Changing affiliation/team clears all selections.
  10. Changing search text clears all selections after the debounced filter event.
  11. A zero-result filter leaves the header disabled and unchecked.
  12. 전체 대상 선택 enters global-all mode, shows the active toolbar state, and opens the batch flow in all_eligible_in_scope mode.
  13. Clicking 전체 대상 선택 해제 clears global-all mode.
  14. 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:

bash
pnpm --filter @hereby/admin exec vitest run src/app-annual-leave-selection.test.ts
pnpm --filter @hereby/admin build
pnpm lint:check
pnpm build

If 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.ts
  • apps/admin/src/views/tabs/leave-management.html
  • apps/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-pressed and 전체 대상 선택 / 전체 대상 선택 해제 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 in apps/employee-app styles and repository utility scripts.
  • Admin package lint also reports pre-existing annual-promotion diagnostics in app.ts and existing main.css rules; no diagnostics were emitted for the new test file.