Skip to content

Payroll Sheet Tests — PR-A (Money + Adapters) Implementation Plan ​

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add frontend unit tests for the payroll-settlement sheet's money/date/sort/summary logic and the read-only domain adapters, fixing the adapter enum-drift bugs found during investigation and pinning correct behavior.

Architecture: Small behavior-preserving refactors first (export private pure fns; extract pure cores from Alpine slice methods so they are directly testable; consolidate the duplicated date normalizer where it's safe to do so). Then co-located Vitest tests. Adapter label maps are aligned to the real @hereby/core enums and asserted enum-driven so future drift fails the build.

Tech Stack: TypeScript (strict), Vitest + happy-dom (apps/hr-system/vitest.config.ts), Testing Library (available), Biome. Package @hereby/hr-system.

Revision note (2026-07-03): This plan was re-verified against current develop HEAD (commit 6907d610) before execution. develop had moved 423 commits past test/unit-coverage-batch1 since the original plan was written two days earlier, so the base-branch strategy, several enum contracts, and one refactor were re-checked and corrected. See the per-task notes below for what changed and why. Two structural changes vs. the 2026-07-01 version: buildColumnsFromLayout is promoted from "deferred to PR-B" into this PR's Tier 1 (Task 6), and a new Task 9 fixes a confirmed money-preview bug in commit-compute.ts's overtime category routing.

Global Constraints ​

  • Runner AND build must both be green. Vitest does not type-check. Every task ends with pnpm --filter @hereby/hr-system exec vitest run <file> and pnpm --filter @hereby/hr-system build.
  • cellRenderers signature is (row, col, rowIdx) => string. Always call renderers with all 3 args in tests (pass undefined as never / 0 for unused), or the build breaks even when the runner is green.
  • Assert against real enums. Adapter label maps assert against @hereby/core enums (SubsidyStatus, ResignationStatus, SettlementStatus, IncentiveCategory), never hand-rolled strings.
  • Test behavior, not implementation. Assert return values / rendered strings. No private-state or "was called" assertions.
  • Refactors are behavior-preserving. Existing tests + build stay green after each refactor, before new tests are added.
  • Commit style: type(scope): subject (English), optional Korean body. Match existing commits (no co-author line seen on recent commits — omit).
  • Branch: all work on test/batch2-sheet off develop (see Task 1 — not off test/unit-coverage-batch1; that plan was corrected).
  • Formatting: this repo's pre-commit hook runs biome check --write via lint-staged on every commit (verified live during Task 1 execution) — it auto-wraps most over-100-char lines in the code blocks below. The one line that can't be auto-wrapped (a biome-ignore comment in Task 3) is already written under 100 chars in this plan.

Task 1: Branch + baseline ​

Status: COMPLETED 2026-07-03 — done directly during plan review because the originally-planned base branch turned out to be unusable. Recorded here so the recipe is reproducible and so later tasks' "confirm baseline green" step has an accurate starting point.

Files: apps/hr-system/src/sheets/overtime.adapter.ts, apps/hr-system/src/sheets/overtime.adapter.test.ts (both modified as part of baseline repair — see Step 3 below).

What changed vs. the original 2026-07-01 plan: that version said to branch off test/unit-coverage-batch1. Re-verification found test/unit-coverage-batch1 is 423 commits behind develop and entirely missing the apps/hr-system/src/sheets/workbook/ submodule split that Tasks 4/6/7/9 below depend on (it still has the pre-split monolithic workbook.sheet.ts). Branching from it would have made every line-number reference in this plan wrong and hidden real work behind a stale base.

  • [x] Step 1: Create the batch2 branch off develop, not off batch1
bash
cd /Users/kimjuhyeon/Documents/GitHub/hereby-dhub
git fetch origin
git switch -c test/batch2-sheet origin/develop
  • [x] Step 2: Cherry-pick only the sheet-relevant commit from batch1

test/unit-coverage-batch1 has exactly 2 commits ahead of its merge-base with develop:

  • 9c1f4a7b — fix(hr-system): align overtime adapter labels to real enums + bind tests to API contract (touches apps/hr-system/src/sheets/overtime.adapter.ts + .test.ts — this is the one to bring forward)
  • a82dbcec — unrelated backend/code-modules test infra + pnpm-lock.yaml changes — do NOT cherry-pick this one; it's out of this batch's frontend/e2e scope and risks a stale lockfile.
bash
git cherry-pick 9c1f4a7b

Expect a conflict on apps/hr-system/src/sheets/overtime.adapter.test.ts (git reports "modify/delete" because the file doesn't exist on develop yet — the batch1 commit that created it, a82dbcec, is being skipped). This is not a real conflict: the version left in the tree by git IS the file you want.

bash
git add apps/hr-system/src/sheets/overtime.adapter.ts apps/hr-system/src/sheets/overtime.adapter.test.ts
git cherry-pick --continue --no-edit
  • [x] Step 3: Fix baseline drift — the cherry-picked test targets a develop that no longer exists

develop moved 423 commits since batch1 forked, and two of the things that changed are exactly what the cherry-picked overtime adapter/test touch:

  1. OvertimeDepartment was removed entirely (migration 1823000000000-DropOvertimeDepartment: "vestige of the removed Department concept... not settable anywhere in the app... hard-defaulted to admin"). The cherry-picked test still imports it and asserts a department cellRenderer/searchPredicate that no longer exists on the adapter (the develop-side auto-merge already dropped the column from overtime.adapter.ts itself — only the test file, which was a pure add, is stale). Fix: delete every OvertimeDepartment/department reference from overtime.adapter.test.ts (the import, the expectedDepartment map, the "matches by the localized department LABEL" test, the "renders the exact Korean label for EVERY OvertimeDepartment" test, the "falls back to a dash for an empty department" test).
  2. OvertimeCategory.HOLIDAY_NIGHT was added (packages/core/src/enums/overtime.enum.ts:18, = "holiday_night"; doc-comment: "휴일야간연장 휴일 야간 초과"). Also note from the same file: SATURDAY is @deprecated since 2026-06 ("토·일을 휴일로 통합"), kept only for legacy data. The cherry-picked adapter's CAT_LABEL and the test's expectedCategory: Record<OvertimeCategory, string> don't have a HOLIDAY_NIGHT entry, which fails tsc (Property '[OvertimeCategory.HOLIDAY_NIGHT]' is missing). Fix: add holiday_night: "휴일야간" to both CAT_LABEL (overtime.adapter.ts) and expectedCategory (overtime.adapter.test.ts), matching the terse style of the existing 평일/토요일/휴일/야간 entries.

These fixes were applied and committed as 433fe910 (fix(hr-system): rebase overtime adapter fix onto develop, drop dead department coverage, add missing HOLIDAY_NIGHT label).

  • [x] Step 4: Confirm the baseline is green

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ Result: PASS — 9 test files, 56 tests.

Run: pnpm --filter @hereby/hr-system build Result: PASS (tsc + vite build).


Task 2: Export the pure compute fns from workbook-adapter.ts ​

Files:

  • Modify: apps/hr-system/src/sheets/workbook-adapter.ts (add export to 3 fns)

Interfaces:

  • Produces: export function evalColumnFilter(row, col, rule), export function pasteNormalizeIsoDate(s): string | null, export function projectRowAmount(row: WorkbookRow, hooks: WorkbookAdapterHooks): number. WorkbookRow (line 34) and WorkbookAdapterHooks (line 1163) are already exported.

Re-verified 2026-07-03 against current develop HEAD: all three line numbers below are exact — workbook-adapter.ts itself was untouched by the 423 commits that moved other sheet files, so nothing drifted here.

  • [ ] Step 1: Add export to the three fns (no body change)

workbook-adapter.ts:176 → export function evalColumnFilter(workbook-adapter.ts:335 → export function pasteNormalizeIsoDate(s: string): string | null {workbook-adapter.ts:1349 → export function projectRowAmount(row: WorkbookRow, hooks: WorkbookAdapterHooks): number {

  • [ ] Step 2: Verify nothing else broke

Run: pnpm --filter @hereby/hr-system build Expected: PASS.

  • [ ] Step 3: Commit
bash
git add apps/hr-system/src/sheets/workbook-adapter.ts
git commit -m "refactor(hr-system): export sheet filter/date/amount fns for unit testing"

Task 3: evalColumnFilter tests ​

Files:

  • Create: apps/hr-system/src/sheets/workbook-adapter.filter.test.ts

  • [ ] Step 1: Write the failing test

ts
import { describe, expect, it } from "vitest";
import type { ColumnDef } from "../sheet/types";
import { evalColumnFilter } from "./workbook-adapter";

const col = (p: Partial<ColumnDef>): ColumnDef => p as ColumnDef;
// biome-ignore lint/suspicious/noExplicitAny: minimal row fixtures for a dot-path read
const row = (o: Record<string, unknown>): any => o;
// biome-ignore lint/suspicious/noExplicitAny: minimal ColumnFilterRule fixture (op/value/valueTo)
const rule = (op: string, value?: unknown, valueTo?: unknown): any => ({ op, value, valueTo });

describe("evalColumnFilter — guards", () => {
  it("passes the row when the column is missing (stale rule for a deleted column)", () => {
    expect(evalColumnFilter(row({}), undefined, rule("equals", "x"))).toBe(true);
  });

  it("isEmpty / isNotEmpty short-circuit before coercion", () => {
    const c = col({ key: "a", render: "text" });
    expect(evalColumnFilter(row({ a: "" }), c, rule("isEmpty"))).toBe(true);
    expect(evalColumnFilter(row({ a: "x" }), c, rule("isEmpty"))).toBe(false);
    expect(evalColumnFilter(row({ a: "x" }), c, rule("isNotEmpty"))).toBe(true);
    expect(evalColumnFilter(row({ a: "" }), c, rule("isNotEmpty"))).toBe(false);
  });

  it("a null value fails every non-presence op", () => {
    const c = col({ key: "a", render: "amount" });
    expect(evalColumnFilter(row({ a: null }), c, rule("gt", 0))).toBe(false);
  });
});

describe("evalColumnFilter — amount", () => {
  const c = col({ key: "amount", field: "amount", render: "amount" });
  it("compares numerically", () => {
    expect(evalColumnFilter(row({ amount: 1000 }), c, rule("equals", 1000))).toBe(true);
    expect(evalColumnFilter(row({ amount: 1000 }), c, rule("gt", 500))).toBe(true);
    expect(evalColumnFilter(row({ amount: 1000 }), c, rule("lt", 500))).toBe(false);
  });
  it("between is inclusive and order-independent", () => {
    expect(evalColumnFilter(row({ amount: 5 }), c, rule("between", 1, 10))).toBe(true);
    expect(evalColumnFilter(row({ amount: 5 }), c, rule("between", 10, 1))).toBe(true);
    expect(evalColumnFilter(row({ amount: 11 }), c, rule("between", 1, 10))).toBe(false);
  });
  it("a non-finite cell fails; a non-finite rule passes", () => {
    expect(evalColumnFilter(row({ amount: "abc" }), c, rule("gt", 1))).toBe(false);
    expect(evalColumnFilter(row({ amount: 1000 }), c, rule("gt", "x"))).toBe(true);
  });
});

describe("evalColumnFilter — date (normalizes both sides to ISO)", () => {
  const c = col({ key: "d", field: "d", render: "date" });
  it("matches a slash cell against an ISO rule", () => {
    expect(evalColumnFilter(row({ d: "4/10/2009" }), c, rule("equals", "2009-04-10"))).toBe(true);
  });
  it("orders chronologically via ISO string compare", () => {
    expect(evalColumnFilter(row({ d: "2009-05-01" }), c, rule("gt", "2009-04-10"))).toBe(true);
  });
  it("an unparseable cell fails", () => {
    expect(evalColumnFilter(row({ d: "garbage" }), c, rule("equals", "2009-04-10"))).toBe(false);
  });
});

describe("evalColumnFilter — string (case-insensitive)", () => {
  const c = col({ key: "name", field: "name", render: "text" });
  it("contains / startsWith / equals ignore case", () => {
    expect(evalColumnFilter(row({ name: "Kim" }), c, rule("contains", "kim"))).toBe(true);
    expect(evalColumnFilter(row({ name: "Kim" }), c, rule("startsWith", "ki"))).toBe(true);
    expect(evalColumnFilter(row({ name: "Kim" }), c, rule("equals", "kim"))).toBe(true);
  });
});
  • [ ] Step 2: Run — expect PASS (the fn already exists; this pins behavior)

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.filter.test.ts Expected: PASS. If any case fails, it is a real bug — stop and report, do not edit the test to match.

  • [ ] Step 3: Type-check

Run: pnpm --filter @hereby/hr-system build Expected: PASS.

  • [ ] Step 4: Commit
bash
git add apps/hr-system/src/sheets/workbook-adapter.filter.test.ts
git commit -m "test(hr-system): cover evalColumnFilter operators + date/amount coercion"

Task 4: Consolidate date normalization + tests ​

Revised 2026-07-03: the original version of this task delegated BOTH pasteNormalizeIsoDate (workbook-adapter.ts) AND _normalizeToIsoDate (workbook/drafts.ts) to one shared helper. Re-verification found they are not behaviorally identical: in the ISO branch, pasteNormalizeIsoDate interpolates the raw regex-captured year string (iso[1]), while drafts.ts's _normalizeToIsoDate first does Number(iso[1]) and interpolates the number. These diverge for any leading-zero 4-digit year (e.g. "0024-01-05" → "0024-01-05" vs "24-01-05"). This is a narrow edge case (leading-zero years never occur in real payroll dates), but delegating both to a single helper as originally planned would silently change drafts.ts's behavior for that edge case — not behavior-preserving as specified. Fix: only pasteNormalizeIsoDate delegates to the shared helper in this task. drafts.ts is left untouched. The duplication note below is updated to reflect two copies remain (this file's, drafts.ts's), not three-down-to-one.

Files:

  • Create: apps/hr-system/src/sheets/workbook/date-normalize.ts
  • Create: apps/hr-system/src/sheets/workbook/date-normalize.test.ts
  • Modify: apps/hr-system/src/sheets/workbook-adapter.ts (pasteNormalizeIsoDate delegates)

Interfaces:

  • Produces: export function normalizeToIsoDate(s: string): string | null

  • [ ] Step 1: Create the shared helper (verbatim logic from pasteNormalizeIsoDate)

ts
// apps/hr-system/src/sheets/workbook/date-normalize.ts
/**
 * Normalize a supported date string to canonical ISO yyyy-mm-dd, or null.
 * Extracted from workbook-adapter's pasteNormalizeIsoDate. NOT shared with
 * drafts.ts's _normalizeToIsoDate or the resolver's toIsoDate — both have a
 * confirmed behavioral difference in ISO-year handling (see Task 4 notes in
 * the implementation plan) and were left as separate copies on purpose.
 * Accepts: yyyy-mm-dd | yyyy/m/d | m/d/yyyy (flips to d/m/yyyy when the
 * first segment >12) | m/d/yy (00-69 -> 2000s, 70-99 -> 1900s).
 * NOTE: does NOT validate calendar validity (e.g. "2/30/2025" passes through).
 */
export function normalizeToIsoDate(s: string): string | null {
  if (!s) {
    return null;
  }
  const trimmed = s.trim();
  const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(trimmed);
  if (iso) {
    const mm = String(Number(iso[2])).padStart(2, "0");
    const dd = String(Number(iso[3])).padStart(2, "0");
    return `${iso[1]}-${mm}-${dd}`;
  }
  const slash = /^(\d{1,4})\/(\d{1,2})\/(\d{1,4})$/.exec(trimmed);
  if (!slash) {
    return null;
  }
  const a = Number(slash[1]);
  const b = Number(slash[2]);
  const c = Number(slash[3]);
  let y: number;
  let m: number;
  let d: number;
  if (slash[1].length === 4) {
    y = a;
    m = b;
    d = c;
  } else {
    const yr = slash[3].length === 4 ? c : c <= 69 ? 2000 + c : 1900 + c;
    if (a > 12 && b <= 12) {
      y = yr;
      m = b;
      d = a;
    } else {
      y = yr;
      m = a;
      d = b;
    }
  }
  return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}
  • [ ] Step 2: Write the test
ts
// apps/hr-system/src/sheets/workbook/date-normalize.test.ts
import { describe, expect, it } from "vitest";
import { normalizeToIsoDate } from "./date-normalize";

describe("normalizeToIsoDate", () => {
  it("zero-pads an ISO date", () => {
    expect(normalizeToIsoDate("2025-1-5")).toBe("2025-01-05");
  });
  it("reads yyyy/m/d as year-first", () => {
    expect(normalizeToIsoDate("2025/1/5")).toBe("2025-01-05");
  });
  it("reads m/d/yyyy as month-first", () => {
    expect(normalizeToIsoDate("3/4/2009")).toBe("2009-03-04");
  });
  it("flips to d/m/yyyy when the first segment cannot be a month", () => {
    expect(normalizeToIsoDate("13/1/2025")).toBe("2025-01-13");
  });
  it("pivots a 2-digit year: 00-69 -> 2000s", () => {
    expect(normalizeToIsoDate("1/5/25")).toBe("2025-01-05");
  });
  it("pivots a 2-digit year: 70-99 -> 1900s", () => {
    expect(normalizeToIsoDate("1/5/70")).toBe("1970-01-05");
  });
  it("returns null for unparseable / empty input", () => {
    expect(normalizeToIsoDate("garbage")).toBeNull();
    expect(normalizeToIsoDate("")).toBeNull();
  });
  it("does NOT validate calendar validity (characterization — locks current behavior)", () => {
    // characterization: locks current behavior, not proven-correct output
    expect(normalizeToIsoDate("2/30/2025")).toBe("2025-02-30");
  });
});
  • [ ] Step 3: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/date-normalize.test.ts Expected: PASS.

  • [ ] Step 4: Point pasteNormalizeIsoDate at the shared helper (do NOT touch drafts.ts — see the revision note above)

In workbook-adapter.ts: add import { normalizeToIsoDate } from "./workbook/date-normalize"; and replace the body of pasteNormalizeIsoDate with return normalizeToIsoDate(s); (keep the exported signature from Task 2).

  • [ ] Step 5: Verify runner + build (existing filter test still green proves behavior preserved)

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.filter.test.ts src/sheets/workbook/date-normalize.test.ts Expected: PASS.

Run: pnpm --filter @hereby/hr-system build Expected: PASS.

  • [ ] Step 6: Commit
bash
git add apps/hr-system/src/sheets/workbook/date-normalize.ts apps/hr-system/src/sheets/workbook/date-normalize.test.ts apps/hr-system/src/sheets/workbook-adapter.ts
git commit -m "refactor(hr-system): extract pasteNormalizeIsoDate into a tested date-normalize helper"

Task 5: projectRowAmount tests ​

Files:

  • Create: apps/hr-system/src/sheets/workbook-adapter.amount.test.ts

  • [ ] Step 1: Write the test (exercises hook vs fallback)

ts
import { describe, expect, it } from "vitest";
import { projectRowAmount } from "./workbook-adapter";

// biome-ignore lint/suspicious/noExplicitAny: minimal WorkbookRow fixtures
const row = (o: Record<string, unknown>): any => o;

describe("projectRowAmount", () => {
  it("overtime uses the preview hook when it returns a number", () => {
    expect(
      projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {
        previewOvertimeAmount: () => 500,
      }),
    ).toBe(500);
  });
  it("overtime falls back to row.amount when the hook returns null", () => {
    expect(
      projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {
        previewOvertimeAmount: () => null,
      }),
    ).toBe(100);
  });
  it("overtime falls back to row.amount when no hook is provided", () => {
    expect(projectRowAmount(row({ recordKind: "overtime", amount: 100 }), {})).toBe(100);
  });
  it("deduction uses the insurance preview hook", () => {
    expect(
      projectRowAmount(row({ recordKind: "deduction", amount: 100 }), {
        previewInsuranceAmount: () => -30,
      }),
    ).toBe(-30);
  });
  it("other kinds use row.amount, coercing non-numeric to 0", () => {
    expect(projectRowAmount(row({ recordKind: "allowance", amount: 2500 }), {})).toBe(2500);
    expect(projectRowAmount(row({ recordKind: "allowance", amount: "abc" }), {})).toBe(0);
  });
});
  • [ ] Step 2: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.amount.test.ts Expected: PASS.

  • [ ] Step 3: Type-check + commit

Run: pnpm --filter @hereby/hr-system build → PASS.

bash
git add apps/hr-system/src/sheets/workbook-adapter.amount.test.ts
git commit -m "test(hr-system): cover projectRowAmount hook vs fallback"

Task 6: Export buildColumnsFromLayout + tests ​

New task, promoted from "deferred to PR-B" (2026-07-03 revision). The 2026-07-01 plan deferred this function citing "body not yet read." It has since been read in full (workbook-adapter.ts:946-1161, confirmed unchanged on current develop): it is closure-free (zero this references — verified by grep), depends only on module-level FALLBACK_LAYOUT/DEFAULT_COL_META constants and its layout parameter, and its job is exactly the kind of silent-breakage money-classification logic this batch targets (it decides each persisted column's render/field/width from a layout entry — a wrong classification means a money column silently renders as plain text, or vice versa). It belongs in Tier 1 with the rest of the money-compute functions, not deferred.

Files:

  • Modify: apps/hr-system/src/sheets/workbook-adapter.ts (add export to buildColumnsFromLayout, no body change)
  • Create: apps/hr-system/src/sheets/workbook-adapter.columns.test.ts

Interfaces:

  • Produces: export function buildColumnsFromLayout(layout: SheetViewLite["columnLayout"]): ColumnDef[]

  • [ ] Step 1: Add export (no body change)

workbook-adapter.ts:946 → export function buildColumnsFromLayout(layout: SheetViewLite["columnLayout"]): ColumnDef[] {

  • [ ] Step 2: Write the test

Read apps/hr-system/src/sheets/workbook-adapter.ts lines 946-1161 and the DEFAULT_COL_META/FALLBACK_LAYOUT consts it reads from (search above line 946 in the same file) before writing assertions — the exact per-key metadata (which keys default to render: "amount" vs "text", which are frozen, minWidth defaults) must be read from the real consts, not guessed. Write at minimum:

ts
// apps/hr-system/src/sheets/workbook-adapter.columns.test.ts
import { describe, expect, it } from "vitest";
import { buildColumnsFromLayout } from "./workbook-adapter";

describe("buildColumnsFromLayout", () => {
  it("force-includes an index column at the start when the persisted layout lacks one", () => {
    const cols = buildColumnsFromLayout([{ key: "employeeName", width: 90, visible: true }]);
    expect(cols[0]?.key).toBe("index");
    expect(cols[0]?.frozen).toBe(true);
  });

  it("falls back to FALLBACK_LAYOUT when the persisted layout is empty", () => {
    const withEmpty = buildColumnsFromLayout([]);
    const withUndefined = buildColumnsFromLayout(undefined as never);
    expect(withEmpty.length).toBeGreaterThan(1);
    expect(withEmpty.map((c) => c.key)).toEqual(withUndefined.map((c) => c.key));
  });

  it("classifies a known amount-bearing key with render: 'amount'", () => {
    const cols = buildColumnsFromLayout([{ key: "amount", width: 100, visible: true }]);
    const amountCol = cols.find((c) => c.key === "amount");
    expect(amountCol?.render).toBe("amount");
  });

  it("preserves the persisted width and visibility from the layout entry", () => {
    const cols = buildColumnsFromLayout([{ key: "employeeName", width: 123, visible: false }]);
    const col = cols.find((c) => c.key === "employeeName");
    expect(col?.width).toBe(123);
    expect(col?.visible).toBe(false);
  });
});

Adjust the exact expected render/metadata values in tests 3-4 to match what DEFAULT_COL_META actually contains once read — do not guess field names blind.

  • [ ] Step 3: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook-adapter.columns.test.ts Expected: PASS (this pins existing behavior — if a case fails, the assertion was wrong about DEFAULT_COL_META's real content; fix the test, not the source, since Step 1 made no body change).

  • [ ] Step 4: Build + commit

Run: pnpm --filter @hereby/hr-system build → PASS.

bash
git add apps/hr-system/src/sheets/workbook-adapter.ts apps/hr-system/src/sheets/workbook-adapter.columns.test.ts
git commit -m "test(hr-system): export + cover buildColumnsFromLayout column classification"

Task 7: Extract compareByColumn + tests ​

Re-verified 2026-07-03 against current develop HEAD: filters-sort.ts's inline sort is still at lines 119-135 exactly, logic unchanged.

Files:

  • Create: apps/hr-system/src/sheets/workbook/sort-compare.ts
  • Create: apps/hr-system/src/sheets/workbook/sort-compare.test.ts
  • Modify: apps/hr-system/src/sheets/workbook/filters-sort.ts:119-135 (delegate)

Interfaces:

  • Produces: export function compareByColumn(a: unknown, b: unknown, sortKey: string, dir: "asc" | "desc"): number

  • [ ] Step 1: Create the pure comparator (verbatim from sortByColumn's inline sort)

ts
// apps/hr-system/src/sheets/workbook/sort-compare.ts
/**
 * Pure comparator extracted from filters-sort's sortByColumn. Null values
 * sort last regardless of direction (Excel convention). Strings use the
 * Korean locale; other values coerce via Number() (non-numeric -> 0).
 */
export function compareByColumn(
  a: unknown,
  b: unknown,
  sortKey: string,
  dir: "asc" | "desc",
): number {
  const multiplier = dir === "asc" ? 1 : -1;
  const va = (a as Record<string, unknown> | null | undefined)?.[sortKey];
  const vb = (b as Record<string, unknown> | null | undefined)?.[sortKey];
  if (va == null && vb == null) {
    return 0;
  }
  if (va == null) {
    return 1;
  }
  if (vb == null) {
    return -1;
  }
  if (typeof va === "string" && typeof vb === "string") {
    return va.localeCompare(vb, "ko") * multiplier;
  }
  return ((Number(va) || 0) - (Number(vb) || 0)) * multiplier;
}
  • [ ] Step 2: Write the test
ts
// apps/hr-system/src/sheets/workbook/sort-compare.test.ts
import { describe, expect, it } from "vitest";
import { compareByColumn } from "./sort-compare";

const r = (v: unknown) => ({ v });

describe("compareByColumn", () => {
  it("orders numbers ascending and descending", () => {
    expect(compareByColumn(r(1), r(2), "v", "asc")).toBeLessThan(0);
    expect(compareByColumn(r(1), r(2), "v", "desc")).toBeGreaterThan(0);
  });
  it("sorts nulls last regardless of direction", () => {
    expect(compareByColumn(r(null), r(1), "v", "asc")).toBe(1);
    expect(compareByColumn(r(null), r(1), "v", "desc")).toBe(1);
    expect(compareByColumn(r(null), r(null), "v", "asc")).toBe(0);
  });
  it("uses Korean locale collation for strings", () => {
    expect(compareByColumn(r("나"), r("가"), "v", "asc")).toBeGreaterThan(0);
  });
  it("coerces non-numeric values to 0", () => {
    expect(compareByColumn(r("abc"), r(5), "v", "asc")).toBeLessThan(0);
  });
});
  • [ ] Step 3: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/sort-compare.test.ts Expected: PASS.

  • [ ] Step 4: Delegate from sortByColumn — replace the inline .sort((a, b) => { ... }) body (filters-sort.ts:119-135) with:
ts
      (this.engine.rows as unknown[]).sort((a, b) => compareByColumn(a, b, sortKey, dir));

and add import { compareByColumn } from "./sort-compare"; at the top of filters-sort.ts.

  • [ ] Step 5: Verify build (behavior preserved)

Run: pnpm --filter @hereby/hr-system build → PASS.

  • [ ] Step 6: Commit
bash
git add apps/hr-system/src/sheets/workbook/sort-compare.ts apps/hr-system/src/sheets/workbook/sort-compare.test.ts apps/hr-system/src/sheets/workbook/filters-sort.ts
git commit -m "refactor(hr-system): extract compareByColumn; cover null-last + ko locale sort"

Task 8: Extract summaryValue + tests ​

Re-verified 2026-07-03 against current develop HEAD: payroll.ts's summaryFor is still at lines 633-713 exactly, every aggregation rule below re-confirmed against current source line-by-line.

Files:

  • Modify: apps/hr-system/src/sheets/workbook/payroll.ts (add exported summaryValue; summaryFor delegates)
  • Create: apps/hr-system/src/sheets/workbook/payroll-summary.test.ts

Interfaces:

  • Consumes: WorkbookRow from ../workbook-adapter, ColumnDef from ../../sheet/types.

  • Produces: export function summaryValue(col: ColumnDef, rows: WorkbookRow[]): string

  • [ ] Step 1: Add the pure fn to payroll.ts (verbatim body from summaryFor, minus the engineTick/engine plumbing) and export it. Ensure imports for ColumnDef and WorkbookRow exist in the file.

ts
export function summaryValue(col: ColumnDef, rows: WorkbookRow[]): string {
  const visible = rows.filter((r) => !r.isSubtotal);
  if (col.render === "amount") {
    const field = (col.field ?? col.key) as keyof WorkbookRow;
    let sum = 0;
    for (const r of visible) {
      const v = r[field] as number | null | undefined;
      if (v != null) {
        sum += Number(v) || 0;
      }
    }
    return Math.round(sum).toLocaleString("ko-KR");
  }
  if (col.key === "overtimeHours" || col.key === "mealBreakHours") {
    const field = col.field as keyof WorkbookRow;
    let sum = 0;
    for (const r of visible) {
      const v = r[field] as number | null | undefined;
      if (v != null) {
        sum += Number(v) || 0;
      }
    }
    return sum.toFixed(1);
  }
  if (col.key === "unitCount") {
    let sum = 0;
    for (const r of visible) {
      if (r.unitCount != null) {
        sum += Number(r.unitCount) || 0;
      }
    }
    return sum.toLocaleString("ko-KR");
  }
  if (col.key === "therapistCount") {
    let sum = 0;
    for (const r of visible) {
      if (r.therapistCount != null) {
        sum += Number(r.therapistCount) || 0;
      }
    }
    return sum.toLocaleString("ko-KR");
  }
  if (col.key === "extendNet") {
    let sum = 0;
    for (const r of visible) {
      const ot = Number(r.overtimeHours ?? 0);
      const meal = Number(r.mealBreakHours ?? 0);
      sum += Math.max(ot - meal, 0);
    }
    return sum.toFixed(1);
  }
  if (col.key === "employeeNumber" || col.key === "employeeName") {
    const seen = new Set<string>();
    for (const r of visible) {
      if (r.employeeId) {
        seen.add(r.employeeId);
      }
    }
    return `${seen.size}명`;
  }
  return "";
}

Then make the slice method delegate — replace the body of summaryFor(col) (after the engineTick read + if (!this.engine) return "";) with:

ts
      return summaryValue(col, this.engine.filteredSortedRows as WorkbookRow[]);
  • [ ] Step 2: Write the test
ts
// apps/hr-system/src/sheets/workbook/payroll-summary.test.ts
import { describe, expect, it } from "vitest";
import type { ColumnDef } from "../../sheet/types";
import { summaryValue } from "./payroll";

const col = (p: Partial<ColumnDef>): ColumnDef => p as ColumnDef;
// biome-ignore lint/suspicious/noExplicitAny: minimal WorkbookRow[] fixtures
const rows = (...rs: Record<string, unknown>[]): any => rs;

describe("summaryValue", () => {
  it("sums amount columns with a Korean thousands separator", () => {
    const c = col({ render: "amount", key: "amount", field: "amount" });
    expect(summaryValue(c, rows({ amount: 1000 }, { amount: 2500 }))).toBe("3,500");
  });
  it("excludes subtotal rows from the footer sum", () => {
    const c = col({ render: "amount", key: "amount", field: "amount" });
    expect(
      summaryValue(c, rows({ amount: 1000 }, { amount: 9999, isSubtotal: true })),
    ).toBe("1,000");
  });
  it("sums overtimeHours to one decimal", () => {
    const c = col({ key: "overtimeHours", field: "overtimeHours" });
    expect(summaryValue(c, rows({ overtimeHours: 1.5 }, { overtimeHours: 2 }))).toBe("3.5");
  });
  it("sums unitCount as an integer", () => {
    const c = col({ key: "unitCount" });
    expect(summaryValue(c, rows({ unitCount: 3 }, { unitCount: 4 }))).toBe("7");
  });
  it("computes extendNet as sum of max(ot - meal, 0)", () => {
    const c = col({ key: "extendNet" });
    expect(summaryValue(c, rows({ overtimeHours: 5, mealBreakHours: 1 }))).toBe("4.0");
  });
  it("counts distinct employees under identity columns", () => {
    const c = col({ key: "employeeName" });
    expect(
      summaryValue(c, rows({ employeeId: "e1" }, { employeeId: "e1" }, { employeeId: "e2" })),
    ).toBe("2명");
  });
  it("returns '' for columns with no summary rule", () => {
    expect(summaryValue(col({ key: "foo", render: "text" }), rows({ foo: "x" }))).toBe("");
  });
});
  • [ ] Step 3: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/payroll-summary.test.ts Expected: PASS.

  • [ ] Step 4: Build + commit

Run: pnpm --filter @hereby/hr-system build → PASS.

bash
git add apps/hr-system/src/sheets/workbook/payroll.ts apps/hr-system/src/sheets/workbook/payroll-summary.test.ts
git commit -m "refactor(hr-system): extract summaryValue; cover per-column footer aggregation"

Task 9: Fix overtime category routing in commit-compute.ts (confirmed money-preview bug) ​

New task (2026-07-03 revision). _previewOvertimeAmount in apps/hr-system/src/sheets/workbook/commit-compute.ts (lines 754-800) computes the live amount preview shown before a commit. Its category routing (lines 787-796) only special-cases OvertimeCategory.HOLIDAY and .NIGHT; every other category — including .SATURDAY (deprecated legacy data) and .HOLIDAY_NIGHT — falls into a generic else branch that treats the hours as plain weekdayHours.

This was traced end-to-end and confirmed as a real underpayment bug, not a false alarm. The rate module it calls (packages/code-modules/src/modules/overtime-record.ts) has independently-configurable multipliers per category — overtimeMultiplier (weekday, default 1.5x), nightMultiplier (night, default 0.5x — an additive premium, not a full rate), holidayMultiplier (holiday, default 1.5x) — and its compute() function sums weekday_base + night_base + holiday_base + weekend_base independently, meaning setting both holidayHours and nightHours to the same net hours correctly stacks the two premiums. This matches the enum's own documented Korean-law semantics (overtime.enum.ts:9: HOLIDAY_NIGHT 휴일야간연장 휴일 야간 초과, and docs/ko/policy/01_Domains/employee/page.md:169: "휴일 근무 시간을 초과한 근무이면서... 야간 시간대... 해당하는 근무"). Concretely, at default rates: HOLIDAY_NIGHT hours currently preview at 1.5x (routed to weekdayHours) but should preview at 1.5x + 0.5x = 2.0x — a guaranteed 0.5×hourlyRate×hours underpayment preview on every holiday-night overtime row. SATURDAY (legacy, @deprecated since the 2026-06 Saturday/Sunday-into-Holiday unification) is also mis-routed to weekdayHours instead of holidayHours, which is wrong whenever an org's overtimeMultiplier and holidayMultiplier differ (a common Korean labor-compliance setup).

This is frontend preview logic (apps/hr-system, not packages/api), so it stays in scope for this batch per the earlier scope decision — backend packages/api/src/routers/sheet-rows/ commit-time computation is untouched (it has its own equivalent logic and is explicitly out of scope; see spec Section 8-adjacent handoff note. If this same category-routing pattern exists in the backend calculator, that is a separate finding for the backend owner — do not fix it here.).

Files:

  • Create: apps/hr-system/src/sheets/workbook/overtime-category.ts
  • Create: apps/hr-system/src/sheets/workbook/overtime-category.test.ts
  • Modify: apps/hr-system/src/sheets/workbook/commit-compute.ts:787-796 (delegate)

Interfaces:

  • Produces: export interface OvertimeCategoryHours { weekdayHours: number; nightHours: number; nightDays: number; holidayHours: number; holidayDays: number; }, export function categorizeOvertimeHours(cat: OvertimeCategory | null | undefined, netHours: number): OvertimeCategoryHours

  • [ ] Step 1: Write the failing test

ts
// apps/hr-system/src/sheets/workbook/overtime-category.test.ts
import { OvertimeCategory } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { categorizeOvertimeHours } from "./overtime-category";

describe("categorizeOvertimeHours", () => {
  it("routes WEEKDAY to weekdayHours", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.WEEKDAY, 3)).toEqual({
      weekdayHours: 3,
      nightHours: 0,
      nightDays: 0,
      holidayHours: 0,
      holidayDays: 0,
    });
  });

  it("routes HOLIDAY to holidayHours", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY, 3)).toEqual({
      weekdayHours: 0,
      nightHours: 0,
      nightDays: 0,
      holidayHours: 3,
      holidayDays: 1,
    });
  });

  it("routes NIGHT to nightHours", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.NIGHT, 3)).toEqual({
      weekdayHours: 0,
      nightHours: 3,
      nightDays: 1,
      holidayHours: 0,
      holidayDays: 0,
    });
  });

  it("stacks HOLIDAY_NIGHT into holiday+night hours (regression: fell to weekdayHours)", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY_NIGHT, 3)).toEqual({
      weekdayHours: 0,
      nightHours: 3,
      nightDays: 1,
      holidayHours: 3,
      holidayDays: 1,
    });
  });

  it("routes deprecated SATURDAY to holidayHours, not weekdayHours (org rate)", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.SATURDAY, 3)).toEqual({
      weekdayHours: 0,
      nightHours: 0,
      nightDays: 0,
      holidayHours: 3,
      holidayDays: 1,
    });
  });

  it("never sets a *Days flag for zero net hours", () => {
    expect(categorizeOvertimeHours(OvertimeCategory.HOLIDAY, 0).holidayDays).toBe(0);
  });

  it("falls back to weekdayHours for a null or undefined category", () => {
    expect(categorizeOvertimeHours(null, 3).weekdayHours).toBe(3);
    expect(categorizeOvertimeHours(undefined, 3).weekdayHours).toBe(3);
  });
});
  • [ ] Step 2: Run — expect FAIL (module doesn't exist yet)

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/overtime-category.test.ts Expected: FAIL — cannot find module ./overtime-category.

  • [ ] Step 3: Write the implementation
ts
// apps/hr-system/src/sheets/workbook/overtime-category.ts
import { OvertimeCategory } from "@hereby/core";

export interface OvertimeCategoryHours {
  weekdayHours: number;
  nightHours: number;
  nightDays: number;
  holidayHours: number;
  holidayDays: number;
}

/**
 * Maps an overtime category to its rate-calculation hour buckets, consumed by
 * packages/code-modules' overtimeRecordModule.compute (weekday/night/holiday
 * multipliers are independently additive there). HOLIDAY_NIGHT stacks BOTH
 * holiday and night buckets for the same hours — 휴일야간연장 legally earns the
 * holiday premium AND the night premium together, not one or the other.
 * SATURDAY is a @deprecated legacy category (Sat/Sun unified into HOLIDAY
 * since 2026-06); still routed to holidayHours so old data previews at the
 * current policy's rate rather than the plain weekday rate.
 */
export function categorizeOvertimeHours(
  cat: OvertimeCategory | null | undefined,
  netHours: number,
): OvertimeCategoryHours {
  const hours: OvertimeCategoryHours = {
    weekdayHours: 0,
    nightHours: 0,
    nightDays: 0,
    holidayHours: 0,
    holidayDays: 0,
  };
  switch (cat) {
    case OvertimeCategory.HOLIDAY:
    case OvertimeCategory.SATURDAY:
      hours.holidayHours = netHours;
      hours.holidayDays = netHours > 0 ? 1 : 0;
      break;
    case OvertimeCategory.NIGHT:
      hours.nightHours = netHours;
      hours.nightDays = netHours > 0 ? 1 : 0;
      break;
    case OvertimeCategory.HOLIDAY_NIGHT:
      hours.holidayHours = netHours;
      hours.holidayDays = netHours > 0 ? 1 : 0;
      hours.nightHours = netHours;
      hours.nightDays = netHours > 0 ? 1 : 0;
      break;
    default:
      hours.weekdayHours = netHours;
  }
  return hours;
}
  • [ ] Step 4: Run — expect PASS

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/workbook/overtime-category.test.ts Expected: PASS.

  • [ ] Step 5: Delegate from _previewOvertimeAmount — replace lines 787-796 of commit-compute.ts:
ts
      const cat = row.overtimeCategory as OvertimeCategory | null | undefined;
      if (cat === OvertimeCategory.HOLIDAY) {
        inputs.holidayHours = netHours;
        inputs.holidayDays = netHours > 0 ? 1 : 0;
      } else if (cat === OvertimeCategory.NIGHT) {
        inputs.nightHours = netHours;
        inputs.nightDays = netHours > 0 ? 1 : 0;
      } else {
        inputs.weekdayHours = netHours;
      }

with:

ts
      const cat = row.overtimeCategory as OvertimeCategory | null | undefined;
      Object.assign(inputs, categorizeOvertimeHours(cat, netHours));

and add import { categorizeOvertimeHours } from "./overtime-category"; at the top of commit-compute.ts.

  • [ ] Step 6: Verify build + full sheet test suite

Run: pnpm --filter @hereby/hr-system build → PASS. Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ → PASS (confirms this didn't regress anything else touching commit-compute.ts).

  • [ ] Step 7: Commit
bash
git add apps/hr-system/src/sheets/workbook/overtime-category.ts apps/hr-system/src/sheets/workbook/overtime-category.test.ts apps/hr-system/src/sheets/workbook/commit-compute.ts
git commit -m "fix(hr-system): stack holiday+night premiums for HOLIDAY_NIGHT overtime preview, route legacy SATURDAY to holiday rate"

Task 10: Education adapter — align to SubsidyStatus, test ​

Re-verified 2026-07-03: STATUS_LABEL still has the fictional draft key, still not exported, current develop content unchanged from what this task assumes.

Files:

  • Modify: apps/hr-system/src/sheets/education.adapter.ts (export + align STATUS_LABEL)
  • Create: apps/hr-system/src/sheets/education.adapter.test.ts

Real enum: SubsidyStatus = pending, approved, paid, cancelled. Current map has a fictional draft.

  • [ ] Step 1: Write the failing test
ts
import { SubsidyStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildEducationAdapter, STATUS_LABEL } from "./education.adapter";

// biome-ignore lint/suspicious/noExplicitAny: sdk unused on the pure render/search paths
const a = buildEducationAdapter({} as any);
// biome-ignore lint/suspicious/noExplicitAny: partial EducationRow fixtures
const row = (o: Record<string, unknown>): any => o;

describe("education adapter — STATUS_LABEL matches SubsidyStatus", () => {
  const real = new Set<string>(Object.values(SubsidyStatus));
  it("labels every real status", () => {
    for (const v of real) {
      expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
    }
  });
  it("carries no fictional status keys", () => {
    for (const k of Object.keys(STATUS_LABEL)) {
      expect(real.has(k), `${k} is not a real SubsidyStatus`).toBe(true);
    }
  });
});

describe("education adapter — renderers (3-arg) + search", () => {
  it("badge renders the Korean label and escapes the raw status", () => {
    const html = a.cellRenderers?.badge(row({ status: "approved" }), {} as never, 0);
    expect(html).toContain("승인");
  });
  it("check renders a tick only when verified", () => {
    expect(a.cellRenderers?.check(row({ receiptVerified: true }), {} as never, 0)).toContain("✓");
    expect(
      a.cellRenderers?.check(row({ receiptVerified: false }), {} as never, 0),
    ).not.toContain("✓");
  });
  it("searchPredicate matches number, name, and course", () => {
    expect(a.searchPredicate?.(row({ courseName: "CPR 교육" }), "cpr")).toBe(true);
    expect(a.searchPredicate?.(row({ employeeName: "김간호" }), "김")).toBe(true);
  });
});
  • [ ] Step 2: Run — expect FAIL (fictional draft key)

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/education.adapter.test.ts Expected: FAIL on "carries no fictional status keys" (draft).

  • [ ] Step 3: Fix the adapter — export the map and drop the fictional key:
ts
export const STATUS_LABEL: Record<string, string> = {
  pending: "대기",
  approved: "승인",
  paid: "지급",
  cancelled: "취소",
};
  • [ ] Step 4: Run — expect PASS, then build.

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/education.adapter.test.ts → PASS. Run: pnpm --filter @hereby/hr-system build → PASS.

  • [ ] Step 5: Commit
bash
git add apps/hr-system/src/sheets/education.adapter.ts apps/hr-system/src/sheets/education.adapter.test.ts
git commit -m "fix(hr-system): align education adapter status labels to SubsidyStatus + test"

Task 11: Resignation adapter — align to ResignationStatus, test ​

Re-verified 2026-07-03: STATUS_LABEL still has all 6 fictional keys, current develop content unchanged from what this task assumes.

Files:

  • Modify: apps/hr-system/src/sheets/resignation.adapter.ts
  • Create: apps/hr-system/src/sheets/resignation.adapter.test.ts

Real enum: ResignationStatus = pending, processing, settled, completed. Current map has 6 fictional keys (draft, approved, confirmed, paid, cancelled, corrected).

  • [ ] Step 1: Write the failing test (same enum-driven shape, enum swapped)
ts
import { ResignationStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildResignationAdapter, STATUS_LABEL } from "./resignation.adapter";

// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildResignationAdapter({} as any);
// biome-ignore lint/suspicious/noExplicitAny: partial ResignationRow fixtures
const row = (o: Record<string, unknown>): any => o;

describe("resignation adapter — STATUS_LABEL matches ResignationStatus", () => {
  const real = new Set<string>(Object.values(ResignationStatus));
  it("labels every real status", () => {
    for (const v of real) {
      expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
    }
  });
  it("carries no fictional status keys", () => {
    for (const k of Object.keys(STATUS_LABEL)) {
      expect(real.has(k), `${k} is not a real ResignationStatus`).toBe(true);
    }
  });
});

describe("resignation adapter — badge + search", () => {
  it("badge renders the Korean label", () => {
    const html = a.cellRenderers?.badge(row({ status: "completed" }), {} as never, 0);
    expect(html).toContain("완료");
  });
  it("searchPredicate matches number and name", () => {
    expect(a.searchPredicate?.(row({ employeeNumber: "E-9" }), "e-9")).toBe(true);
  });
});
  • [ ] Step 2: Run — expect FAIL (fictional keys).

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/resignation.adapter.test.ts Expected: FAIL on fictional keys.

  • [ ] Step 3: Fix the adapter
ts
export const STATUS_LABEL: Record<string, string> = {
  pending: "대기",
  processing: "처리중",
  settled: "정산",
  completed: "완료",
};
  • [ ] Step 4: Run PASS + build PASS + commit
bash
git add apps/hr-system/src/sheets/resignation.adapter.ts apps/hr-system/src/sheets/resignation.adapter.test.ts
git commit -m "fix(hr-system): align resignation adapter status labels to ResignationStatus + test"

Task 12: Year-end adapter — align to SettlementStatus, test ​

Re-verified 2026-07-03: STATUS_LABEL still missing calculated/applied and still carries all 6 fictional keys, current develop content unchanged from what this task assumes. This remains the highest-severity adapter drift (two real, reachable statuses currently render as raw enum text in the Korean UI).

Files:

  • Modify: apps/hr-system/src/sheets/yearend.adapter.ts
  • Create: apps/hr-system/src/sheets/yearend.adapter.test.ts

Real enum: SettlementStatus = pending, calculated, approved, applied, completed. Current map is missing calculated and applied and carries fictional draft/confirmed/paid/cancelled/processing/settled.

  • [ ] Step 1: Write the failing test
ts
import { SettlementStatus } from "@hereby/core";
import { describe, expect, it } from "vitest";
import { buildYearEndAdapter, STATUS_LABEL } from "./yearend.adapter";

// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildYearEndAdapter({} as any, 2025);
// biome-ignore lint/suspicious/noExplicitAny: partial YearEndRow fixtures
const row = (o: Record<string, unknown>): any => o;

describe("yearend adapter — STATUS_LABEL matches SettlementStatus", () => {
  const real = new Set<string>(Object.values(SettlementStatus));
  it("labels every real status (regression: 'calculated' + 'applied' were missing)", () => {
    for (const v of real) {
      expect(STATUS_LABEL[v], `missing label for ${v}`).toBeTruthy();
    }
  });
  it("carries no fictional status keys", () => {
    for (const k of Object.keys(STATUS_LABEL)) {
      expect(real.has(k), `${k} is not a real SettlementStatus`).toBe(true);
    }
  });
});

describe("yearend adapter — badge", () => {
  it("renders the Korean label for a previously-unlabelled status", () => {
    const html = a.cellRenderers?.badge(row({ status: "calculated" }), {} as never, 0);
    expect(html).not.toContain("calculated");
  });
});
  • [ ] Step 2: Run — expect FAIL (missing calculated/applied + fictional keys).

  • [ ] Step 3: Fix the adapter

ts
export const STATUS_LABEL: Record<string, string> = {
  pending: "대기",
  calculated: "산출",
  approved: "승인",
  applied: "반영",
  completed: "완료",
};
  • [ ] Step 4: Run PASS + build PASS + commit
bash
git add apps/hr-system/src/sheets/yearend.adapter.ts apps/hr-system/src/sheets/yearend.adapter.test.ts
git commit -m "fix(hr-system): add missing SettlementStatus labels (calculated/applied) to yearend adapter + test"

Task 13: Sports adapter — dead code confirmed; flag + pure-logic test ​

Re-verified 2026-07-03 — reachability is now DEFINITIVE, not conditional. The original plan's Step 1 was a grep-and-decide branch ("if live, fix; if dead, flag"). Re-verification ran that grep against current develop and found:

  • sportsSheetComponent (built from buildSportsAdapter) IS registered with Alpine in apps/hr-system/src/main.ts (Alpine.data("_sportsSheet", sportsSheetComponent))...
  • ...but rg -n "_sportsSheet" apps/hr-system -g '*.html' returns zero matches — no template anywhere mounts x-data="_sportsSheet" — and no tab-config array in apps/hr-system/src lists "sports" as an active tab id.
  • sdk.sportsIncentive is confirmed absent from HerebyClient (packages/sdk/src/client/HerebyClient.ts has no such property; fixedIncentive is the real registered service). This exact gap is independently corroborated by two pre-existing FIXME(타입 복구 중 발견) comments already in apps/hr-system/src/app.ts (lines 2065, 2275) for the same missing-SDK-method issue in unrelated code.

Conclusion: the sports tab is registered but unreachable from any live view — confirmed dead code. Skip the "fix the SDK binding" branch entirely; do not attempt to repoint loadRows to fixedIncentive, since that would be speculative behavior for a tab nobody can navigate to. Flag it and move on.

Files:

  • Modify: apps/hr-system/src/sheets/sports.adapter.ts (add FIXME comment only — no functional change)

  • Create: apps/hr-system/src/sheets/sports.adapter.test.ts

  • [ ] Step 1: Add the dead-code flag

Above loadRows in sports.adapter.ts, add:

ts
    // FIXME(타입 복구 중 발견): sports 탭은 Alpine 등록만 있고(_sportsSheet)
    // 마운트하는 HTML도, 탭 설정도 없음 — 도달 불가능한 죽은 코드.
    // sdk.sportsIncentive도 HerebyClient에 없음(fixedIncentive만 존재,
    // app.ts:2065/2275의 FIXME와 동일 문제). PR에서 탭 제거를 제안.
    // 제거 결정 전까지 SDK 바인딩은 손대지 않음(존재하지 않는 서비스를
    // 추측으로 다른 서비스에 재연결하지 않음).
  • [ ] Step 2: Write the pure-logic test (reachability-independent — the functions still exist and are worth pinning even for dead code, in case removal is deferred)
ts
import { describe, expect, it } from "vitest";
import { buildSportsAdapter } from "./sports.adapter";

// biome-ignore lint/suspicious/noExplicitAny: sdk unused on pure paths
const a = buildSportsAdapter({} as any, { year: 2025, month: 3 });
// biome-ignore lint/suspicious/noExplicitAny: partial SportsRow fixtures
const row = (o: Record<string, unknown>): any => o;

describe("sports adapter — pure rendering", () => {
  it("yearMonth renders zero-padded YYYY/MM", () => {
    expect(a.cellRenderers?.yearMonth(row({ year: 2025, month: 3 }), {} as never, 0)).toContain(
      "2025/03",
    );
  });
  it("buildDefaultColumns binds amount to the amount field with render 'amount'", () => {
    const amountCol = a.buildDefaultColumns().find((c) => c.key === "amount");
    expect(amountCol?.field).toBe("amount");
    expect(amountCol?.render).toBe("amount");
  });
  it("searchPredicate matches employee number", () => {
    expect(a.searchPredicate?.(row({ employeeNumber: "S-1" }), "s-1")).toBe(true);
  });
});
  • [ ] Step 3: Run PASS + build PASS + commit
bash
git add apps/hr-system/src/sheets/sports.adapter.ts apps/hr-system/src/sheets/sports.adapter.test.ts
git commit -m "chore(hr-system): flag sports adapter as unreachable dead code + pin pure rendering logic"

Task 14: PR-A close ​

  • [ ] Step 1: Full verification

Run: pnpm lint:check Expected: PASS (run pnpm lint:fix if it reports fixable issues, then re-run).

Run: pnpm build Expected: PASS.

Run: pnpm --filter @hereby/hr-system exec vitest run src/sheets/ src/sheet/ Expected: PASS.

  • [ ] Step 2: Push + open PR-A
bash
git push -u origin test/batch2-sheet

PR body (template .github/pull_request_template.md): ## Summary (money/adapter unit tests + adapter enum-drift fixes + the HOLIDAY_NIGHT/SATURDAY overtime-preview money fix), ## 요약, ## Test plan (한글 체크리스트). Note in the body: the base-branch correction (develop, not stale batch1); sports tab reachability finding (confirmed dead code, propose removal separately); and that the multi-tenancy leak is handed off separately (see spec Section 8).


Deferred to follow-up plans ​

  • PR-B (Tier 3 interaction + Tier 4 engine): _detectFillSeries→fill-series.ts, _parseTsvImport, _isCellEditableForRow, _resolveEmployeeForPaste, treatment transposed/matrix; virtualizer, render/formatAmount, selection (extend), engine.moveSelection/fillSelection; subtotal-generation money tests. Separate plan file. (buildColumnsFromLayout — previously listed here — moved into this PR as Task 6; commit-compute.ts's overtime routing — not previously listed anywhere — moved into this PR as Task 9.)
  • PR-C (Playwright e2e 21–26): payroll confirm/cancel, filter+sort, formula-commit-amount, paste-fill, carry-forward apply, export.
  • Security (backend, separate owner): multi-tenancy leak fix + regression — spec Section 8. If the backend's own overtime commit-compute has the same HOLIDAY_NIGHT/SATURDAY category-routing gap as the frontend preview fixed in Task 9, that is a separate backend finding to hand off — not verified or fixed here (backend packages/api is out of this batch's scope).

Self-review notes (2026-07-03 revision) ​

  • Every task's file paths and line numbers were re-verified against current develop HEAD (commit 6907d610) via parallel independent verification agents plus direct rg/sed checks, not carried over unchecked from the 2026-07-01 draft. Two real drifts were found and fixed (Task 1's base branch; Task 4's date-normalize scope). One real gap was found and closed (Task 6, buildColumnsFromLayout promoted from a vague "deferred" bucket into a concrete Tier-1 task). One real, previously-undocumented money bug was found, traced to its actual rate-multiplier config, and given its own fix+test task (Task 9).
  • Spec coverage: PR-A now covers spec §5 (refactors — with Task 4's scope corrected), §6 Tier 1 (money — filter/date/amount/sort/summary/columns, all six) and Tier 2 (adapters + E1 fixes; overtime rebased onto develop in Task 1). Tier 3/4 + e2e + multi-tenancy tracked under "Deferred". The commit-compute.ts money bug was not in the original spec at all — it surfaced during plan review, not during the original investigation; the spec doc itself does not need edits since Task 9 is a natural extension of the "Fix the adapter bugs found during investigation" goal statement in spec §1, not a scope departure.
  • Placeholder scan: none — every step has real code or an exact command.
  • Type consistency: normalizeToIsoDate, compareByColumn, summaryValue, categorizeOvertimeHours/OvertimeCategoryHours, STATUS_LABEL/TYPE_LABEL exports referenced consistently across tasks; WorkbookRow/WorkbookAdapterHooks are already-exported from workbook-adapter.ts.
  • Biome line-width: fenced code blocks in this document were checked line-by-line against the 100-char limit; the one line that cannot be auto-fixed by the repo's pre-commit biome check --write hook (a biome-ignore comment, since formatters don't reflow comments) is written at 98 chars in Task 3. Multi-line expect(...) calls that would exceed 100 chars on one line are pre-wrapped in this document; the pre-commit hook will still reformat exact wrapping to Biome's own style on first commit — that is expected and not a plan defect.