Leave — entitlement, balance & multi-stage approval

The whole leave model reduces to one idea: a Leave is a dated request (fromDate → toDate) for a leaveType, whose entitlement comes from the employee's assigned LeaveGroup, whose duration is computed from the working-day calendar, and whose approval is delegated to the generic workflow-approval-engine. Balance is not stored — it is derived on demand as entitled − Σ duration(PENDING_APPROVAL + APPROVED) per leave type. There is no accrual ledger and no carry-forward: entitlement is a flat per-year number on the group.

Source: BE src/modules/hr/leave (+ leave/group) · Admin src/modules/hr/leave (+ leave/group)


1. Purpose & scope

The leave module owns the employee absence request lifecycle and entitlement model. It is responsible for:

  • Leave groups (leave/group): named policies that list which leave types an employee may take and how many days of each (numberOfDays) plus whether each is paid (isPaid). An employee is wired to one group via Employee.leaveGroupId (see employee).
  • Leave requests (leave): a per-employee, per-date-range application for a leave type, with paid/half-day flags, reason, and file attachments.
  • Balance derivation (myLeaveBalance): live computation of entitled / used / remaining per leave type — no stored balance.
  • Duration computation: counting working days in the requested range, excluding weekends and company holidays (via calendar).
  • Routing approval through the shared workflow-approval-engine (the orchestrator resolves manager/HOD/HR approvers from the approval-policy).

It explicitly does not:

  • Store or accrue balances. There is no LeaveBalance collection, no monthly accrual job, no carry-forward / forfeiture logic. "Balance" is recomputed every read from the group entitlement minus used days. Flag (see §4, §9).
  • Define leave types. Leave types are master-data records (masters collection, key: "leave_type") — annual_leave, sick_leave, casual_leave, maternity_leave, paternity_leave, unpaid_leave, etc. (master/constants.ts). The group only references them by leaveTypeId. See master.
  • Approve leaves itself. Approval state is owned by the workflow engine; the leave service only flips its own status via engine callbacks (workflowCompleted/workflowRejected). There is no direct approve/reject mutation on the leave resolver (contrast timesheet, which approves in-module).
  • Deduct payroll directly. Payroll reads approved leaves to compute unpaid-leave deductions and absence handling — see §8 and payroll.

2. Data model

Two collections: leave_groups (the policy) and leaves (the requests). Both @HrSchema-decorated (hr_ semantics) and soft-deleted via mongoose-delete (deletedAt: true). BaseSchema contributes _id, companyId, branchId, ref, documentCode/Date, createdAt/By, updatedAt/By, deleted, canUpdate/View/Delete/Post, client. *Id setters coerce hex strings to ObjectId.

2.1 leave_groups collection — the entitlement policy

Schema: leave/group/group.schema.ts (LeaveGroup extends BaseSchema, @HrSchema({ collection: "leave_groups" })).

field type required description
name string yes Group label (e.g. "Standard Staff", "Executives").
types GroupType[] (embedded) The leave-type allowances. Each entry's leaveTypeId setter coerces to ObjectId.

GroupType (embedded sub-document — the entitlement line):

field type required description
leaveTypeId ObjectId → masters (leave_type) Which master leave type this allowance is for.
numberOfDays number The annual entitlement for this type (the entitled figure in balance math). 0/unset = no cap (treated as unlimited — see §4.2).
isPaid boolean (default true) Whether leaves of this type are paid. Drives payroll: unpaid (false) → salary deduction. Used as the default isPaid on a new request.

The repository's $lookupLeaveGroupTypes aggregation joins each types[].leaveTypeId against masters to attach types[].leaveType (the master doc with name) for display.

2.2 leaves collection — the request

Schema: leave/leave.schema.ts (Leave extends BaseSchema, @HrSchema({ collection: "leaves" })).

field type required description
employeeId ObjectId → employees yes The requester.
leaveTypeId ObjectId → masters (leave_type) yes The leave type (must exist in the employee's group types).
leaveGroupId ObjectId → leave_groups Snapshot of the employee's group at request time (copied from employee.leaveGroupId on create).
status enum LeaveStatus (default PENDING_APPROVAL) Lifecycle state. GraphQL-registered.
fromDate number (unix ms) yes Start of leave. Also the repository dateKey for range filtering.
toDate number (unix ms) yes End of leave (inclusive).
duration number (default 0) Derived working-day count (see §4.1). 0.5 if isHalfDay. This is the value summed for "used days" and payroll deduction.
isHalfDay boolean (default false) Half-day request → duration = 0.5.
isPaid boolean (default true) Paid vs unpaid. Resolved from request → group-type → default true (see §4.3). Unpaid → payroll deducts.
reasonForLeave string yes Free-text reason.
files string[] (default []) Attachment URLs (supporting docs, e.g. MC for sick leave). See files-assets-upload.

GraphQL-only / derived fields (on the Leave ObjectType, not stored): employeeName (joined from employees → users.name), leaveTypeName (joined from masters), approvalId (exposed nullable but never populated by the resolvers — flag).

Multi-tenancy: companyId is copied from the employee on create (companyId: employee.companyId). The repository buildQuery applies the same ESS self-scoping as timesheet: if contextSvc.employeeId is set, the query is forced to that employee — an ESS user only ever sees their own leaves.

2.3 Enum

// leave.schema.ts (mirrored in admin model.ts and GraphQL)
export enum LeaveStatus {
  PENDING          = "PENDING",           // declared but UNUSED by the create flow (see §9)
  PENDING_APPROVAL = "PENDING_APPROVAL",  // default on create — awaiting workflow approval
  APPROVED         = "APPROVED",          // set by workflowCompleted() callback
  REJECTED         = "REJECTED",          // set by workflowRejected() callback
  CANCELLED        = "CANCELLED",         // set by cancelLeave() (only from PENDING_APPROVAL)
}

PENDING exists in the enum (and is GraphQL-registered) but no code path ever sets it — every new leave starts at PENDING_APPROVAL. It appears in admin filters/colour maps only. Flag.

2.4 LeaveBalanceItem (computed, not a collection)

@ObjectType() class LeaveBalanceItem {
  leaveTypeName: string;   // master name (or "Leave" fallback)
  entitled: number;        // GroupType.numberOfDays
  used: number;            // Σ duration of PENDING_APPROVAL + APPROVED leaves of this type
  remaining: number;       // max(0, entitled − used)
}

3. API surface

GraphQL — leave requests (leave.resolver.ts)

Guards: @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE"). Resolver extends ApBaseResolver<Leave>.

Operation Type Input Returns Audit Notes
createLeave Mutation CreateLeaveInput Leave CREATE Validates group/type/balance/approval chain, computes duration, submits to workflow.
cancelLeave Mutation id Leave STATUS_CHANGE Only from PENDING_APPROVAL; archives the workflow task.
leaveById Query id Leave (nullable) Joins employee name + leave-type name.
leavePage Query LeavePageInput LeavePageResult Paginated; joins leaveTypeName. ESS self-scoped.
myLeaves Query LeavePageInput LeavePageResult Forces `employeeId = user.employeeId
myLeaveBalance Query [LeaveBalanceItem] Live balance for the current user (see §4.2).

There is no approveLeave/rejectLeave mutation here. Approval happens on the workflow side (the approver acts on a workflow task / approvals inbox); the engine then calls back into the leave service to flip status.

GraphQL — leave groups (leave/group/group.resolver.ts)

Same guards. Resolver extends ApBaseResolver<LeaveGroup>.

Operation Type Input Returns Audit
createLeaveGroup Mutation CreateLeaveGroupInput { name, types[] } LeaveGroup CREATE
updateLeaveGroup Mutation id, UpdateLeaveGroupInput (PartialType) LeaveGroup UPDATE
deleteLeaveGroup Mutation id Boolean DELETE
leaveGroupPage Query LeaveGroupPageInput { skip, take, keyword? } LeaveGroupPageResult
findLeaveGroup Query LeaveGroupQueryInput { _id?, name? } [LeaveGroup]
findOneLeaveGroup Query id LeaveGroup (nullable)

Input DTOs

@InputType() class CreateLeaveInput {
  employeeId: ID; leaveTypeId: ID; fromDate: number; toDate: number;
  reasonForLeave: string; isHalfDay?: boolean; isPaid?: boolean; files?: string[];
}                                  // NB: no `duration` — server computes it; no `status` — forced PENDING_APPROVAL.
@InputType() class LeavePageInput { skip; take; status?: LeaveStatus; employeeId?; fromDate?; toDate?; }
@InputType() class GroupTypeInput { leaveTypeId: ID; numberOfDays: number; isPaid?: boolean = true; }
@InputType() class LeaveGroupCommonInput { name: string; types: GroupTypeInput[]; }

No class-validator decorators — validation is service-level (§4) plus GraphQL nullability.

REST

None.


4. Business rules & calculations

4.1 Duration = working days in range (via calendar)

On create, LeaveService.createLeave computes:

const holidays = await calendarSvc.findHolidays(employee.companyId, fromDate, toDate);
const duration = calendarSvc.countWorkingDays(fromDate, toDate, holidays, isHalfDay);

CalendarService.countWorkingDays (calendar/calendar.service.ts):

DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5];   // Mon–Fri (0=Sun … 6=Sat)

countWorkingDays(from, to, holidayDates, isHalfDay) {
  if (isHalfDay) return 0.5;
  // count every calendar day in [from, to] whose weekday ∈ workingDays AND is not a holiday
  return workingDayStrings(from, to, holidayDates).size;
}

Duration = number of Mon–Fri days between fromDate and toDate inclusive, minus company holidays; or 0.5 if isHalfDay. Holidays come from the calendar (isHoliday: true, per companyId). The working week is hardcoded Mon–Fri here — countWorkingDays accepts a workingDays param but the leave flow never passes a per-employee/shift schedule, so leave duration ignores attendance shift configuration. Flag (see §9).

4.2 Balance = entitled − used (derived, never stored)

myLeaveBalance(employeeId):

  1. Load employee → its leaveGroupId → the LeaveGroup (with types). No group → [].
  2. Load all the employee's leaves with status ∈ {PENDING_APPROVAL, APPROVED}.
  3. Sum duration per leaveTypeIdusedByType.
  4. For each GroupType:
entitled  = type.numberOfDays ?? 0
used      = usedByType[typeId] ?? 0
remaining = max(0, entitled − used)

Remaining = numberOfDays − Σ duration(PENDING_APPROVAL + APPROVED) for that type, floored at 0. Pending requests count against the balance (reserved as soon as submitted). REJECTED/CANCELLED leaves are excluded (they free up the days). There is no accrual (days don't drip in monthly), no pro-rating by join date, and no carry-forward between years — numberOfDays is a flat figure applied for the whole life of the group assignment. Flag.

4.3 Create validation & invariants (createLeave)

In order, before any write:

  1. Employee + group required — load employee; if no leaveGroupId"Employee has no leave group assigned".
  2. Approval chain resolvableorchestrator.validateAndResolveStages(employeeId, Leave) runs first so a leave is never created if no approver can be resolved (no policy, no manager/HOD/HR). Throws e.g. "No approval policy configured for Leave" or "No approvers could be resolved…".
  3. Type allowed in group — the group's types must contain leaveTypeId, else "Leave type is not available in employee's leave group".
  4. Balance check — sum existing PENDING_APPROVAL + APPROVED durations of that type; if entitled > 0 && usedDays + duration > entitled"Insufficient leave balance. You have N day(s) remaining for this leave type." (When entitled === 0/unset the check is skipped → effectively unlimited.)
  5. Paid resolutionisPaid = input.isPaid ?? groupType.isPaid ?? true (request overrides group default; group default overrides hard-default true).
  6. Persist + submit inside withRetryTransaction("create_leave"):
    • leaveRepo.create({ ...input, isPaid, leaveGroupId, companyId, duration, status: PENDING_APPROVAL }).
    • orchestrator.resolveAndSubmit({ refId, kind: Leave, employeeId, submitterId: employeeId, ref }) → workflow engine creates approval stages/tasks.

4.4 Status state machine

                       createLeave
                          │ (validate → persist → workflow.submit)
                          ▼
                  ┌────────────────┐
                  │ PENDING_APPROVAL│ ◀── default; balance reserved; workflow task(s) live
                  └────────────────┘
                    │        │        │
       cancelLeave  │        │        │  workflow engine outcome
   (archive task)   ▼        │        ▼
            ┌───────────┐    │   ┌──────────┐   workflowCompleted(leaveId)
            │ CANCELLED │    │   │ APPROVED │ ◀── all stages approved
            └───────────┘    │   └──────────┘
                             ▼
                       ┌──────────┐  workflowRejected(leaveId)
                       │ REJECTED │ ◀── any stage rejected
                       └──────────┘
  • PENDING_APPROVAL → APPROVED: set by LeaveService.workflowCompleted(leaveId) — the callback the workflow engine invokes (via WorkflowTaskFactory) when the final stage is approved.
  • PENDING_APPROVAL → REJECTED: set by LeaveService.workflowRejected(leaveId) when any approver rejects.
  • PENDING_APPROVAL → CANCELLED: cancelLeave(id)only allowed from PENDING_APPROVAL (else "Cannot cancel a leave with status <status>"); runs in a transaction and calls orchestrator.cancelWorkflow(id)archiveByRefId to clear stale approver-inbox tasks.
  • APPROVED, REJECTED, CANCELLED are terminal. There is no edit/update mutation for a leave request — wrong details must be cancelled and re-applied.

4.5 Workflow integration (how leave plugs into the engine)

LeaveService implements IWorkflowTaskKindService and on onModuleInit() registers itself: taskFactory.register(WorkflowTaskKind.Leave, this). The flow:

createLeave → orchestrator.resolveAndSubmit(kind=Leave)
   → orchestrator.validateAndResolveStages: load approval-policy(kind=LEAVE, companyId)
        → for each policy level (sorted by order), map role → approver User._id:
             MANAGER            → employee.reportingTo → that employee's userId
             HEAD_OF_DEPARTMENT → employee.department.hodId (already a User._id)
             HR                 → employee.hrId → that employee's userId
        → build DynamicStage[] { name, order, approveBy (ANY|ALL), approverIds }
   → workflowEngine.submit({ refId: leave._id, kind: Leave, dynamicStages })
        → engine creates stages + WorkflowApproval/Task rows for approvers

[approver acts in approvals inbox] → workflowEngine.approve/reject
   → on final approve → taskFactory.getTaskKindService(Leave).workflowCompleted(leaveId) → status=APPROVED
   → on reject        → …workflowRejected(leaveId)                                       → status=REJECTED

The approval policy (hr_approval_policies, kind=LEAVE) defines the levels (role, order, approveBy); roles resolve to concrete users from the employee's reportingTo/hrId and the department's hodId. See approval-policy, approvals, and the full engine in workflow-approval-engine.

Key gotcha: reportingTo and hrId are Employee._id and are re-resolved to User._id by resolveEmployeeUserId (because WorkflowApproval.approverId matches a User._id); department.hodId is already a User._id. If none of these are set on the employee, leave creation fails up front (step 2). See employee §9 — note reportingTo is not user-editable via the standard form, which can block leave for employees without an imported reporting line.

4.6 Side effects & transactionality

  • Create: writes the leaves row + workflow stages/tasks atomically inside withRetryTransaction("create_leave"); audit CREATE snapshot.
  • Cancel: status flip + archiveByRefId inside withRetryTransaction("cancel_leave"); audit STATUS_CHANGE.
  • Approve/reject: owned by the workflow engine's own transaction; the leave-status flip happens in the callback (leaveRepo.update).
  • No GL legs, no stock. Payroll consumption is read-only at run time (§8).

5. Permissions

  • Feature gate: @RequireFeature("HR_MODULE") + GqlFeatureGuard on both resolvers.
  • Auth: @ApGqlAuthorize() (JWT). myLeaves/myLeaveBalance scope to the caller's employee via @GqlCurrentUser().
  • ESS self-scoping (repository): buildQuery forces employeeId = contextSvc.employeeId for employee callers, so an ESS user cannot read others' leaves via leavePage/leaveById.
  • RBAC (admin-enforced): USER_ACCESS.LEAVES = { MODULE: 'leaves', ACTIONS: { view, create, update, delete, cancel } } and USER_ACCESS.LEAVE_GROUPS = { MODULE: 'leave-groups', ACTIONS: { view, create, update, delete } } (zerp-admin/src/constants/UserAccess.ts). Admin buttons gate on create. As with timesheet, the BE resolvers apply only the feature gate — action-level RBAC is enforced UI-side. Flag.
  • Approver authority is governed by the workflow engine (who appears as an approver on a stage), not by the leave module. See permissions-access.

6. Flows

6.1 Apply for leave (happy path)

Admin /hr/leave (or ESS) → "New Leave" → CreateLeave (Formik modal)
  → createLeave({ employeeId, leaveTypeId, fromDate, toDate, reasonForLeave, isHalfDay, isPaid })
  → LeaveResolver.create → LeaveService.createLeave
       1. employee + leaveGroupId present?            → else "Employee has no leave group assigned"
       2. orchestrator.validateAndResolveStages(Leave) → else policy/approver errors
       3. leaveTypeId ∈ group.types?                  → else "Leave type is not available…"
       4. usedDays + duration ≤ entitled?             → else "Insufficient leave balance…"
       5. isPaid = input.isPaid ?? groupType.isPaid ?? true
       6. tx: leaveRepo.create(status=PENDING_APPROVAL, duration, leaveGroupId, companyId)
              → orchestrator.resolveAndSubmit → workflowEngine.submit(dynamicStages)
  → audit CREATE ; ← Leave (PENDING_APPROVAL)  →  approver(s) notified via workflow task

6.2 Approve / reject (workflow side)

Approver → Approvals inbox (workflow task for refId=leave._id)
  → approve → workflowEngine.approve → (final stage) → LeaveService.workflowCompleted(leaveId) → status=APPROVED
  → reject  → workflowEngine.reject  →                  LeaveService.workflowRejected(leaveId)  → status=REJECTED

(Multi-level: each policy level is a stage; approveBy=ANY needs one approver, ALL needs every approver in that stage. See workflow-approval-engine.)

6.3 Cancel

Requester/admin → row action "Cancel" (shown only for PENDING_APPROVAL)
  → cancelLeave(id) → assert status==PENDING_APPROVAL
  → tx: status=CANCELLED + orchestrator.cancelWorkflow(id) (archive approver tasks)
  ← Leave (CANCELLED) — reserved days freed in next balance read

6.4 Balance read

ESS/detail → myLeaveBalance → load group.types → sum used (PENDING_APPROVAL+APPROVED) per type
  → [{ leaveTypeName, entitled, used, remaining=max(0, entitled−used) }]

6.5 Unhappy paths

  • No leave group on employee → "Employee has no leave group assigned".
  • No approval policy / no resolvable approver → thrown before any DB write (leave not created).
  • Leave type not in the group → "Leave type is not available in employee's leave group".
  • Over balance → "Insufficient leave balance. You have N day(s) remaining…".
  • Cancel a non-PENDING_APPROVAL leave → "Cannot cancel a leave with status <status>".

7. Admin UI

Area Route Module
Leave requests (table + calendar) /hr/leave, /hr/leave/[id] src/modules/hr/leave
Leave groups (policies) /hr/leave-groups (group page) src/modules/hr/leave/group

Leave requests page (page.tsx)

  • Two views toggled in the header: Table and Calendar (ApCalendar). Calendar maps each leave to an event spanning fromDate → toDate, coloured by status (green=APPROVED, orange=PENDING/PENDING_APPROVAL, red=REJECTED, gray=CANCELLED); changing month refetches with that range (pageSize: 100).
  • Table columns: Ref (links to detail), Employee (employeeName / map), Leave Type (leaveTypeName / master map), From, To, Duration (days), Status, Paid (Yes/No), Reason, Actions.
  • Row actions: Cancel (confirm popover) shown only for PENDING_APPROVAL; View Detail (drawer/modal LeaveDetailPage asModal + link to /hr/leave/[id]).
  • Filters: Status select (All + each enum value). fetchMaster() loads leave-type names; fetchEmployeePage({pageSize:1000}) loads employee names.

Create form (components/create.tsx)

Formik modal CreateLeave. Yup: employeeId, leaveType (mixed/object), fromDate, toDate, reasonForLeave all required. Inputs: Employee select (disabled when defaultEmployeeId supplied, e.g. from the employee detail page), ApMasterSelectInput masterKey="leave_type" for the type, From/To date pickers, Reason textarea, and two ApSwitchInput toggles Half Day / Paid Leave (default isPaid: true). Submit maps leaveType?._idleaveTypeId. No duration field — server computes it.

Detail page (detail.tsx)

Read-only: header with employee name, leave-type name, status Tag, Paid/Half-Day tags, short id; From/To/Duration stat block; Reason and Submitted-On. Usable as a full page (/hr/leave/[id]) or embedded drawer (asModal).

Leave groups page (group/page.tsx)

Table of groups: Name + an inline Leave Types sub-table (type name + numberOfDays), with edit/delete row actions and a keyword search. Create/edit via CreateLeaveGroup modal (group/components/create.tsx, async-select.tsx for picking leave-type masters and entering days/paid per row).

Context methods

  • useLeaveState() (leave/context.tsx): fetchLeavePage, fetchLeaveById, fetchLeaveBalance, createLeave, cancelLeave (+ leaves, loading, totalRecords, modal). After create/cancel it toasts and refetches page 1. GraphQL ops (gql/query.ts): LEAVE_PAGE (no-cache), CREATE_LEAVE, CANCEL_LEAVE, LEAVE_BY_ID, MY_LEAVE_BALANCE; errors routed via toastSvc.graphQlError.
  • useLeaveGroupState() (leave/group/context.tsx): fetchLeaveGroupPage, createLeaveGroup, updateLeaveGroup, deleteLeaveGroup.

The employee detail page also embeds a Leaves tab (EMPLOYEE_LEAVES) reusing this context, and can pre-fill the create form with defaultEmployeeId.


8. Dependencies & integrations

Leave depends on / calls:

  • EmployeeModule (EmployeeService) — resolves the employee, its leaveGroupId, companyId, reportingTo/hrId. Hard dependency (forwardRef).
  • LeaveGroupModule (LeaveGroupService) — entitlement lookup.
  • CalendarModule (CalendarService) — findHolidays + countWorkingDays for duration. See calendar.
  • HrApprovalOrchestratorModule (HrApprovalOrchestratorService) → approval-policy + the workflow engine. Hard dependency for approval routing.
  • WorkflowTaskModule (WorkflowTaskFactory) — registers the Leave kind so the engine can call workflowCompleted/workflowRejected.
  • master (leave types via masters, key: "leave_type").
  • AuthModule, SubscriptionModule (feature gate).

Consumed by (read-only):

  • payrollpayroll/employee/employee.service.ts:
    • addUnpaidLeaves: finds status=APPROVED, isPaid=false leaves overlapping the period; deducts Σ (clamped working-days) × dailyRate (half-days honoured; clamped to period boundaries; holidays/non-working days excluded via the shared working-day set). dailyRate = salary / totalWorkingDays. Sets model.leaveIds and model.totalUnpaidLeaveAmount.
    • addAbsenceDeductions: treats all APPROVED leaves (paid or unpaid) as "present" so they aren't double-counted as absence; unpaid is deducted separately by addUnpaidLeaves.
  • hr-dashboardUpcomingLeavesMetrics, onLeaveToday/onLeave headcount metrics, ApprovalsMetrics.leave (pending count).
  • approvals — surfaces the pending workflow tasks created for each leave.

Events: none emitted directly (status changes flow through workflow callbacks, not events). Cron/jobs: none (no accrual job). External: file attachments via the upload pipeline (files-assets-upload).


9. Gotchas & project-specific rules

  1. No stored balance, no accrual, no carry-forward. Balance is recomputed each read as numberOfDays − used. Days do not accrue monthly, are not pro-rated by join date, and do not roll over year-to-year. numberOfDays is a flat lifetime figure on the group. If statutory accrual/carry-forward is required, it must be built (extension point).
  2. entitled === 0 (or unset) means unlimited, not zero. The balance guard is skipped when entitled <= 0, so a group type with numberOfDays: 0 allows unbounded requests of that type.
  3. Pending leaves reserve balance. used sums PENDING_APPROVAL and APPROVED. A stuck pending request keeps its days locked until rejected/cancelled.
  4. Approval is fully delegated to the workflow engine. There is no approve/reject on the leave resolver; status only changes via workflowCompleted/workflowRejected callbacks (or cancelLeave). To debug "why is my leave stuck", look at the workflow tasks/approval-policy, not the leave module.
  5. Leave can be un-creatable for employees with no reporting line. validateAndResolveStages runs before persistence and throws if no manager (reportingTo)/HOD (department.hodId)/HR (hrId) resolves for the policy's levels. Since reportingTo is not editable via the standard employee form (see employee §9), such employees cannot file leave until that link is imported/migrated.
  6. PENDING status is dead. Declared + GraphQL-registered, never set by code. Only PENDING_APPROVAL is used for "awaiting approval".
  7. approvalId field is exposed but never populated. It's on the Leave ObjectType (nullable) yet no resolver sets it — the live approval lives in the workflow tables keyed by refId.
  8. Working week is hardcoded Mon–Fri for duration. countWorkingDays could take a per-shift workingDays, but the leave flow never supplies one — leave duration ignores attendance shift/timetable configuration and only excludes weekends + company holidays.
  9. Half-day = exactly 0.5 day regardless of range. isHalfDay short-circuits to 0.5 even if fromDate ≠ toDate; it does not compute a per-day half across a multi-day range.
  10. leaveGroupId is snapshotted on the leave, but balance reads the current employee group. myLeaveBalance loads the employee's present leaveGroupId, not the one stored on past leaves — changing an employee's group re-bases their computed balance against the new entitlements.
  11. No edit path for a submitted leave. There is no updateLeave; corrections require cancel + re-apply (and cancel is only possible while PENDING_APPROVAL).
  12. Dates are unix-ms numbers (fromDate/toDate); the repository treats fromDate as the range dateKey for leavePage filtering.