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
HrDashboardModulerepository; 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 notrainingMetrics/upcomingLeaves/financialExposurestandalone query — those are only reachable throughdashboardSummary.
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 noresignDate.newHiresThisMonth= employees withjoinDate ∈ [startOfMonth, endOfMonth].vacancies= hardcoded0(TODO — no requisitions/vacancies collection wired; see §9).
4.2 Attendance today (getAttendanceMetrics)
- Group today's (
date = startOfToday)attendancesbyemployeeId, take the laststatusof the day, then$facetcount byPRESENT/ON_LEAVE/ABSENT. untracked=max(0, totalActive − (present + onLeave + absent)).- ⚠ Bug (see §9): it matches
statusvaluesPRESENT/ON_LEAVE/ABSENT, but the realAttendanceStatusenum isREGULAR | LATE | EARLY_LEAVE | OVERTIME. Sopresent/onLeave/absentalways resolve to 0 anduntracked≈totalActive.
4.3 Timesheet completion (getTimesheetMetrics)
currentPeriod="<Month D>-<D, YYYY>"for the current month (dayjs).employeesWithTimesheet= distinctemployeeIdwith ahr_timesheets.datein 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 withstatus = AWAITING_APPROVALbykind. - Reads the
Leave/Advance/Claim/Loankinds;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 (
departmentIdmatch, noresignDate) →headcount; then aggregate today's attendance,$lookupemployees, filter bydepartmentId, group byemployeeId(last status),$facet→presentToday/onLeaveToday/absentToday. - Same
PRESENT/ON_LEAVE/ABSENTstatus mismatch as §4.2 — the today-attendance splits are effectively always 0.headcountis correct.
4.6 Payroll (getPayrollMetrics)
currentPeriod= current month string.status=statusof the latestpayroll(sorted bypayDate desc), defaultDRAFT.pendingPayrollRuns= count of payroll withstatus ∈ {DRAFT, PENDING_APPROVAL}.timesheetCompletionByDept= per-departmentround(employeesWithTimesheet / totalEmployees * 100)for the current month (a single department-rooted pipeline with nested$lookups intoemployeesandhr_timesheets). Caveat: the innerhr_timesheetslookup 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)
$facetoverhr_training_progress:completed(COMPLETED),overdue(OVERDUE),inProgress(IN_PROGRESSorASSIGNED),total.completionRate=round(completed / total * 100).
4.8 Upcoming leaves (getUpcomingLeavesMetrics)
- APPROVED
leavesoverlapping[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 (SetofemployeeId).
4.9 Financial exposure (getFinancialExposureMetrics)
- Loans: APPROVED
loans→activeLoanCount(count) +totalOutstandingLoans($sum remainingBalance). - Advances: APPROVED
advances→activeAdvanceCount(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 calldashboardSummaryif authenticated. - Page-level (admin):
src/pages/hr/index.tsxgetServerSidePropsrunsApGuardBuilder→isAuth()+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.statustype is'DRAFT' | 'LOCKED' | 'PROCESSED', but the BE returns the realPayrollStatus(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) anddayjsfor period math. - No cron, no cache, no external services. Every load recomputes from scratch (
no-cacheon the client too).
9. Gotchas & project-specific rules
- Attendance status mismatch — present/absent/on-leave are effectively always 0. The service filters attendance
statusonPRESENT/ON_LEAVE/ABSENT, but the realAttendanceStatusenum isREGULAR | 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 intountracked. A rebuild must reconcile these status vocabularies (or derive present/absent/on-leave from clock events + approved leave). Confirmed bug. vacanciesis hardcoded0— there is no requisitions/vacancies source (TODO in code). Recruitment (recruitment) is not wired into this number.- No
HR_MODULEfeature 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'). - Per-dept timesheet completion correlation is suspect. In
getPayrollMetrics, the nestedhr_timesheetslookup uses$expr: trueand a literal-string match{"employee.departmentId": "$$deptId"}(a literal, not the bound variable), so per-department timesheet counts may be wrong. Verify before trustingpayroll.timesheetCompletionByDept. - Department breakdown is N+1 by design — one aggregation per department in a JS loop (
getDepartmentBreakdown). Fine for small orgs; revisit for large tenants. - Approvals come from the workflow tasks, not the request docs.
getApprovalsMetricscountsWorkflowTaskStatus.AWAITING_APPROVALtasks by kind — a document whose status is out of sync with its workflow task would be miscounted. Single source: the workflow-task collection. - Stale admin types.
IPayrollMetrics.status('DRAFT' | 'LOCKED' | 'PROCESSED') does not match the BEPayrollStatusunion — purely a typing drift, the rendered string is whatever the BE sends. - Everything is live + uncached (
no-cacheclient, fresh aggregations server-side) — the dashboard reflects the moment it loads; "Last updated" is just the client clock at fetch time. - Active = no
resignDate. There is nostatusfield onemployees; "active" everywhere here meansresignDateabsent (a resigned employee withresignDateset drops out of every headcount). Matches employee §4.3.