HR Dashboard — operational overview / KPI aggregator

The whole HR dashboard reduces to one idea: it owns no data. It is a read-only aggregation layer that fans out across nine existing HR repositories (employee, attendance, timesheet, department, payroll, leave, loan, advance, training-progress) plus the workflow-task service, runs one Mongo aggregation per widget, and rolls the results into a single HrDashboardSummary. Every number on the screen is computed live from the source collections — nothing is stored, cached, or precomputed.

Source: BE src/modules/hr/dashboard (service + resolver + dto + module — no schema/repository) · Admin src/modules/hr-dashboard + page src/pages/hr/index.tsx


1. Purpose & scope

The dashboard is the landing page of the HR admin module (/hr). It presents an operational snapshot for the current company/tenant:

  • Headcount (active staff, new hires this month, vacancies).
  • Attendance today (present / on-leave / absent / untracked).
  • Timesheet completion (% of active staff with entries this period).
  • Pending approvals (leave / claim / loan / advance counts).
  • Department breakdown (per-dept headcount + today's attendance split).
  • Payroll status (latest run status, pending runs, timesheet completion by dept).
  • Training (total / completed / in-progress / overdue + completion rate).
  • Upcoming leaves (7-day window).
  • Financial exposure (active loans + advances outstanding).

It explicitly does NOT:

  • Own any collection or schema. There is no HrDashboardModule repository; the service injects the other modules' repositories directly.
  • Write anything. All methods are reads/aggregations.
  • Apply the @RequireFeature('HR_MODULE') gate — the resolver uses only @ApGqlAuthorize() (JWT). (See §5/§9.)

2. Data model

None of its own. The dashboard reads from these source collections (all companyId-scoped):

Source Collection What the dashboard reads
employee employees active count (resignDate absent), joinDate (new hires), departmentId
attendance attendances today's records grouped by employeeId, latest status
timesheet hr_timesheets distinct employeeId with entries this period
department departments dept list + names
payroll payroll latest run status, pending-run count
leave leaves APPROVED leaves overlapping the next 7 days
loan loans APPROVED loans remainingBalance
advance advances APPROVED advances amount
training hr_training_progress progress status facets
workflow workflow tasks AWAITING_APPROVAL task counts by kind

The output shape (hr-dashboard.dto.ts, all @ObjectType):

HrDashboardSummary {
  headcount:          HeadcountMetrics       { totalActive, vacancies, newHiresThisMonth }
  attendance:         AttendanceMetrics      { present, onLeave, absent, untracked }
  timesheet:          TimesheetMetrics       { currentPeriod, completionPercentage, pendingCount }
  approvals:          ApprovalsMetrics       { totalPending, leave, advance, claim, loan }
  departments:        [DepartmentMetrics]    { _id, name, headcount, presentToday, onLeaveToday, absentToday }
  payroll:            PayrollMetrics         { currentPeriod, status,
                                               timesheetCompletionByDept: [{ department, completionPercentage }],
                                               pendingPayrollRuns }
  training:           TrainingMetrics        { total, completed, inProgress, overdue, completionRate }
  upcomingLeaves:     UpcomingLeavesMetrics  { days: [{ date, count }], totalNextWeek }
  financialExposure:  FinancialExposureMetrics { activeLoanCount, totalOutstandingLoans,
                                                  activeAdvanceCount, totalOutstandingAdvances }
}

3. API surface

hr-dashboard.resolver.ts — all @ApGqlAuthorize() (JWT only; no HR_MODULE feature guard). Each query takes no args and is company-scoped via contextSvc.companyId.

Query Returns Backed by
dashboardSummary HrDashboardSummary getDashboardSummary() — runs all 9 metric methods in Promise.all
headcountMetrics HeadcountMetrics getHeadcountMetrics()
attendanceMetrics AttendanceMetrics getAttendanceMetrics()
timesheetMetrics TimesheetMetrics getTimesheetMetrics()
approvalsMetrics ApprovalsMetrics getApprovalsMetrics()
departmentBreakdown [DepartmentMetrics] getDepartmentBreakdown()
payrollMetrics PayrollMetrics getPayrollMetrics()

The admin only calls dashboardSummary (the one-shot aggregate). The granular per-widget queries exist for piecemeal use but aren't wired in the current UI. There is no trainingMetrics / upcomingLeaves / financialExposure standalone query — those are only reachable through dashboardSummary.


4. Widget data sources & calculations

All aggregations match companyId and (where the source soft-deletes) deleted: { $ne: true }. "Active employee" = resignDate field absent ($exists: false).

4.1 Headcount (getHeadcountMetrics)

  • totalActive = count of employees with no resignDate.
  • newHiresThisMonth = employees with joinDate ∈ [startOfMonth, endOfMonth].
  • vacancies = hardcoded 0 (TODO — no requisitions/vacancies collection wired; see §9).

4.2 Attendance today (getAttendanceMetrics)

  • Group today's (date = startOfToday) attendances by employeeId, take the last status of the day, then $facet count by PRESENT / ON_LEAVE / ABSENT.
  • untracked = max(0, totalActive − (present + onLeave + absent)).
  • ⚠ Bug (see §9): it matches status values PRESENT/ON_LEAVE/ABSENT, but the real AttendanceStatus enum is REGULAR | LATE | EARLY_LEAVE | OVERTIME. So present/onLeave/absent always resolve to 0 and untrackedtotalActive.

4.3 Timesheet completion (getTimesheetMetrics)

  • currentPeriod = "<Month D>-<D, YYYY>" for the current month (dayjs).
  • employeesWithTimesheet = distinct employeeId with a hr_timesheets.date in the current month.
  • completionPercentage = round(employeesWithTimesheet / totalActive * 100) (0 if no active staff).
  • pendingCount = max(0, totalActive − employeesWithTimesheet).

4.4 Pending approvals (getApprovalsMetrics)

  • Calls workflowTaskSvc.countPendingByKind(companyId) → groups workflow tasks with status = AWAITING_APPROVAL by kind.
  • Reads the Leave / Advance / Claim / Loan kinds; totalPending = their sum.
  • Source of truth is the workflow-task collection, not the leave/claim/loan/advance documents — see approvals / workflow-approval-engine.

4.5 Department breakdown (getDepartmentBreakdown)

  • For each department (one query loop, not a single pipeline): count active employees (departmentId match, no resignDate) → headcount; then aggregate today's attendance, $lookup employees, filter by departmentId, group by employeeId (last status), $facetpresentToday / onLeaveToday / absentToday.
  • Same PRESENT/ON_LEAVE/ABSENT status mismatch as §4.2 — the today-attendance splits are effectively always 0. headcount is correct.

4.6 Payroll (getPayrollMetrics)

  • currentPeriod = current month string.
  • status = status of the latest payroll (sorted by payDate desc), default DRAFT.
  • pendingPayrollRuns = count of payroll with status ∈ {DRAFT, PENDING_APPROVAL}.
  • timesheetCompletionByDept = per-department round(employeesWithTimesheet / totalEmployees * 100) for the current month (a single department-rooted pipeline with nested $lookups into employees and hr_timesheets). Caveat: the inner hr_timesheets lookup uses $match: { $expr: true, … } then a stage {$match: {"employee.departmentId": "$$deptId"}} that compares a field to a literal string "$$deptId" rather than the variable — so the per-dept timesheet correlation may not filter as intended (verify before relying on this number).

4.7 Training (getTrainingMetrics)

  • $facet over hr_training_progress: completed (COMPLETED), overdue (OVERDUE), inProgress (IN_PROGRESS or ASSIGNED), total.
  • completionRate = round(completed / total * 100).

4.8 Upcoming leaves (getUpcomingLeavesMetrics)

  • APPROVED leaves overlapping [startOfToday, today+6d endOfDay] (fromDate ≤ weekEnd && toDate ≥ todayStart).
  • days[] = per-day count for the 7-day window (a leave counts on every day it overlaps).
  • totalNextWeek = distinct employees on leave in the window (Set of employeeId).

4.9 Financial exposure (getFinancialExposureMetrics)

  • Loans: APPROVED loansactiveLoanCount (count) + totalOutstandingLoans ($sum remainingBalance).
  • Advances: APPROVED advancesactiveAdvanceCount (count) + totalOutstandingAdvances ($sum amount).
  • Both run in parallel (Promise.all).

Status enums referenced: PayrollStatus, LeaveStatus.APPROVED, LoanStatus.APPROVED, AdvanceStatus.APPROVED, WorkflowTaskStatus.AWAITING_APPROVAL, WorkflowTaskKind.{Leave|Claim|Loan|Advance} — all imported from their source modules.


5. Permissions

  • Resolver-level: @ApGqlAuthorize() (JWT) only. Notably it does NOT carry @RequireFeature('HR_MODULE') like the rest of HR (see hr/_overview §1). A company without the HR subscription can still call dashboardSummary if authenticated.
  • Page-level (admin): src/pages/hr/index.tsx getServerSideProps runs ApGuardBuilderisAuth() + haveModuleAccess('/hr', '/select-module'), redirecting non-HR users away. So module-access gating is enforced at the Next.js page, not the GraphQL resolver.
  • All metrics are tenant-scoped by contextSvc.companyId; there is no branch-level filtering.

6. Flows

Admin opens /hr  →  HRPage (HRLayout) → HRDashboardContent
  useEffect → refetch() (context)                       [no auto-fetch in provider]
  → useHrDashboardQuery.summary()  (useLazyQuery, fetchPolicy: 'no-cache')
  → GQL dashboardSummary
  → HrDashboardResolver.dashboardSummary
  → HrDashboardService.getDashboardSummary()
       Promise.all([ headcount, attendance, timesheet, approvals, departments,
                     payroll, training, upcomingLeaves, financialExposure ])
       each = one (or per-dept several) Mongo aggregation on the source collection
  → HrDashboardSummary → setData → render widgets
  Manual "Refresh" button → refetch() again (no-cache, always fresh)

There are no unhappy-path mutations (read-only). On query error the context sets error; on null data the page shows "No data available".


7. Admin UI

Page: src/pages/hr/index.tsx (/hr) — the HR module home, inside HRLayout (selectedKey="hr-dashboard").

State: src/modules/hr-dashboard/context.tsx (HrDashboardContextProvider / useHrDashboardState) exposes { loading, data, error, refetch }. The provider does not auto-fetch — the page calls refetch() in useEffect. GraphQL via gql/query.ts useHrDashboardQuery().summary (useLazyQuery, fetchPolicy: 'no-cache', errors → toastSvc.graphQlError).

Components (src/modules/hr-dashboard/components/):

Component Renders From
MetricCard 4 executive-summary cards (Headcount, Attendance Today, Timesheet Completion, Pending Approvals) with deep-link hrefs headcount, attendance, timesheet, approvals
ApprovalsGrid Leave / Advance / Claim / Loan pending tiles approvals
DepartmentBreakdownTable Per-dept table: headcount, present/on-leave/absent today departments
PayrollStatusSection Current period, status, pending runs, per-dept timesheet completion payroll
TrainingSection Total/completed/in-progress/overdue + completion rate training
UpcomingLeavesSection 7-day leave bars + total next week upcomingLeaves
FinancialExposureSection Active loans/advances + outstanding totals financialExposure

Layout sections: Executive Summary (4 MetricCards) → Approvals Breakdown → Department Breakdown → Payroll Status → Workforce Insights (Training + Upcoming Leaves side by side) → Financial Exposure. Cards deep-link to /hr/employees, /hr/timesheet?status=pending, /hr/approval. A "Refresh" button re-runs refetch().

The admin IPayrollMetrics.status type is 'DRAFT' | 'LOCKED' | 'PROCESSED', but the BE returns the real PayrollStatus (DRAFT | PENDING_APPROVAL | APPROVED | PAID | CANCELLED) — the admin type is stale (see §9).


8. Dependencies & integrations

HrDashboardModule imports (all forwardRef): AuthModule, EmployeeModule, AttendanceModule, TimesheetModule, DepartmentModule, PayrollModule, LeaveModule, LoanModule, AdvanceModule, TrainingModule, WorkflowTaskModule. Providers: HrDashboardService, HrDashboardResolver. Mounted under HrModule.

  • The service injects each source module's repository directly (employee/attendance/timesheet/department/payroll/leave/loan/advance/training-progress) plus WorkflowTaskService. It is a pure consumer — nothing depends on the dashboard.
  • Uses DateUtils (startOfToday, startOfMonth, endOfMonth) and dayjs for period math.
  • No cron, no cache, no external services. Every load recomputes from scratch (no-cache on the client too).

9. Gotchas & project-specific rules

  1. Attendance status mismatch — present/absent/on-leave are effectively always 0. The service filters attendance status on PRESENT/ON_LEAVE/ABSENT, but the real AttendanceStatus enum is REGULAR | LATE | EARLY_LEAVE | OVERTIME (attendance.schema.ts). The "Attendance Today" card and the per-dept present/on-leave/absent columns will read 0, pushing everyone into untracked. A rebuild must reconcile these status vocabularies (or derive present/absent/on-leave from clock events + approved leave). Confirmed bug.
  2. vacancies is hardcoded 0 — there is no requisitions/vacancies source (TODO in code). Recruitment (recruitment) is not wired into this number.
  3. No HR_MODULE feature gate on the resolver. Unlike every other HR resolver, the dashboard relies on JWT + the Next.js page guard for access control. If you depend on the subscription gate, add @RequireFeature('HR_MODULE').
  4. Per-dept timesheet completion correlation is suspect. In getPayrollMetrics, the nested hr_timesheets lookup uses $expr: true and a literal-string match {"employee.departmentId": "$$deptId"} (a literal, not the bound variable), so per-department timesheet counts may be wrong. Verify before trusting payroll.timesheetCompletionByDept.
  5. Department breakdown is N+1 by design — one aggregation per department in a JS loop (getDepartmentBreakdown). Fine for small orgs; revisit for large tenants.
  6. Approvals come from the workflow tasks, not the request docs. getApprovalsMetrics counts WorkflowTaskStatus.AWAITING_APPROVAL tasks by kind — a document whose status is out of sync with its workflow task would be miscounted. Single source: the workflow-task collection.
  7. Stale admin types. IPayrollMetrics.status ('DRAFT' | 'LOCKED' | 'PROCESSED') does not match the BE PayrollStatus union — purely a typing drift, the rendered string is whatever the BE sends.
  8. Everything is live + uncached (no-cache client, fresh aggregations server-side) — the dashboard reflects the moment it loads; "Last updated" is just the client clock at fetch time.
  9. Active = no resignDate. There is no status field on employees; "active" everywhere here means resignDate absent (a resigned employee with resignDate set drops out of every headcount). Matches employee §4.3.