14 — The analytics page decoded
Read this first: this chapter opens Part 3 by reading the HR Analytics page essentially whole — the newest, most decision-dense corner of the frontend, where every non-obvious choice left a comment behind. You will discover that the page component itself is barely more than a hundred lines, that two innocuous-looking tab props are a chart bug fix, and that a hidden tab costs the backend exactly zero requests.
Time: about 30 minutes. Assumes lesson 13.
A hundred lines that only orchestrate
pages/hr/HrAnalytics.tsx is the 2026 BI redesign's front door: five tabs, a 3D department
landscape, cross-filtering, drill-down drawers, PDF export. Yet the page component holds none of
that. It owns four things — which tab is open, which trend period is selected, whether an export is
running, and a ref to the exportable region — and delegates everything else. Here is the file with
only the imports trimmed:
// imports trimmed …
const TABS = [
{ value: 'overview', label: 'Overview' },
{ value: 'workforce', label: 'Workforce' },
{ value: 'attendance', label: 'Attendance' },
{ value: 'leave', label: 'Leave' },
{ value: 'overtime', label: 'Overtime & Compliance' },
] as const;
const HrAnalytics = () => {
const filters = useHrAnalyticsFilters();
const [tab, setTab] = useState<string>('overview');
const [exporting, setExporting] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const handleExport = async () => {
if (!contentRef.current) return;
setExporting(true);
try {
const blob = await renderNodeToPdfBlob(contentRef.current, 'portrait', {
// Chakra's shadow tokens serialize as CSS `color(srgb …)`, which
// html2canvas can't parse — strip shadows in the clone so capture succeeds.
onclone: (doc) => {
const style = doc.createElement('style');
style.textContent = '*{box-shadow:none !important}';
doc.head.appendChild(style);
},
});
triggerBlobDownload(`HR-Analytics-${new Date().toISOString().slice(0, 10)}.pdf`, blob);
} finally {
setExporting(false);
}
};
return (
<VStack align="stretch" gap={4}>
<HStack justify="space-between" align="flex-start" wrap="wrap" gap={3}>
<VStack flex={1} align="start" gap={1} minW="0">
<Heading size="xl" color="brand.800" _dark={{ color: 'brand.100' }}>HR Analytics</Heading>
<Text fontSize="sm" color="fg.muted" maxW="640px">
Workforce, attendance, leave, overtime, and lifecycle trends for the current employee
population — recruitment has its own analytics page.
</Text>
</VStack>
<HStack gap={2} wrap="wrap">
<NativeSelect.Root size="sm" maxW="170px">
<NativeSelect.Field
aria-label="Trend period"
value={String(filters.months)}
onChange={(e) => filters.setMonths(Number(e.target.value))}
>
<option value="6">Last 6 months</option>
<option value="12">Last 12 months</option>
<option value="24">Last 24 months</option>
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
<Button size="sm" variant="outline" onClick={handleExport} disabled={exporting}>
{exporting ? <Spinner size="xs" /> : <LuDownload />}
Export PDF
</Button>
</HStack>
</HStack>
{filters.department && (
<HStack>
<HStack
gap={1.5} px={3} py={1.5} borderRadius="full" bg="brand.50" color="brand.800"
_dark={{ bg: 'brand.900', color: 'brand.100' }} fontSize="sm" fontWeight="500"
>
<Text>Department: {filters.department}</Text>
<Icon
as={LuX} boxSize={4} cursor="pointer" opacity={0.7} _hover={{ opacity: 1 }}
onClick={filters.clearDepartment} aria-label="Clear department filter"
/>
</HStack>
</HStack>
)}
<Box ref={contentRef}>
{/* lazyMount + unmountOnExit: each tab's charts mount into an already-
visible container, so Recharts' ResponsiveContainer measures a real
width instead of the 0 it would read inside a hidden tab panel. */}
<Tabs.Root value={tab} onValueChange={(e) => setTab(e.value)} variant="enclosed" lazyMount unmountOnExit>
{/* Five triggers outgrow a phone screen — scroll the list instead of
widening the page (the shell header stretches to the widest child). */}
<Tabs.List overflowX="auto" overflowY="hidden" maxW="100%">
{TABS.map((t) => (
<Tabs.Trigger key={t.value} value={t.value} flexShrink={0}>{t.label}</Tabs.Trigger>
))}
</Tabs.List>
<Tabs.Content value="overview">
<OverviewTab months={filters.months} active={tab === 'overview'} filters={filters} />
</Tabs.Content>
<Tabs.Content value="workforce">
<WorkforceTab active={tab === 'workforce'} />
</Tabs.Content>
<Tabs.Content value="attendance">
<AttendanceTab months={filters.months} active={tab === 'attendance'} />
</Tabs.Content>
<Tabs.Content value="leave">
<LeaveTab months={filters.months} active={tab === 'leave'} />
</Tabs.Content>
<Tabs.Content value="overtime">
<OvertimeComplianceTab months={filters.months} active={tab === 'overtime'} filters={filters} />
</Tabs.Content>
</Tabs.Root>
</Box>
</VStack>
);
};
export default HrAnalytics;
The handleExport comment is your first scar of Part 3: Chakra emits shadow tokens in a CSS color
syntax html2canvas cannot parse, so the export strips shadows from the clone it captures — the
live page keeps them. Lesson 20 decodes the whole export path;
today you only need to see that the ref wraps the tabs, so the PDF captures whatever tab is open.
The Tabs.List comment is the second: five triggers outgrow a phone screen, and because the shell
header stretches to the page's widest child, a too-wide tab list would widen everything. The list
scrolls inside itself instead.
Two props that are really a chart bug fix
Predict: delete lazyMount unmountOnExit from Tabs.Root, reload the page, then click over to
Attendance. What do the attendance charts render? Write your answer down before reading on.
The comment above Tabs.Root is the answer, and it is worth re-reading slowly:
{/* lazyMount + unmountOnExit: each tab's charts mount into an already-
visible container, so Recharts' ResponsiveContainer measures a real
width instead of the 0 it would read inside a hidden tab panel. */}
Without lazyMount, all five panels mount on first render — four of them inside hidden containers.
Recharts' ResponsiveContainer sizes charts by measuring its parent DOM node, and a hidden panel
measures width 0. The chart dutifully draws a zero-width SVG: not an error, not a spinner,
nothing. With lazyMount, a tab's component does not exist until you click its trigger, at which
point the panel is already visible and the measurement is real. unmountOnExit completes the
policy: leaving a tab destroys it, so returning later re-runs the same mount-while-visible path
instead of resurrecting a panel that was measured under old conditions.
So the props are not a performance tweak — they are the difference between charts and blank space.
Tab choice is local; filters live in the URL
Notice what is not in the URL: the open tab. const [tab, setTab] = useState<string>('overview')
is plain React state, gone on refresh. But the trend period, the department focus, and an open
department drill-down survive refresh and can be pasted to a colleague — ?months=, ?dept=, and
?drill= are owned by the useHrAnalyticsFilters hook that the page calls on its first line and
threads into the tabs. The rule: state a teammate would want to receive in a link goes in the
URL; navigation ephemera stays in React. Lesson 19 decodes the hook.
Five tabs, one active flag
| Tab | File | What it shows |
|---|---|---|
| Overview | hr-analytics/OverviewTab.tsx | KPI tiles, 3D department landscape, hires vs. exits, headcount trend |
| Workforce | hr-analytics/WorkforceTab.tsx | Status-mix donut; age, tenure, and salary distributions |
| Attendance | hr-analytics/AttendanceTab.tsx | Attendance-rate trend, present/leave/absent stack, lates, missing clock-outs |
| Leave | hr-analytics/LeaveTab.tsx | Usage by type, requests by status, monthly trend, balance-health tables |
| Overtime & Compliance | hr-analytics/OvertimeComplianceTab.tsx | Overtime stacks by month and department, six compliance KPI tiles |
Every Tabs.Content passes active={tab === '…'}, and every tab hands that flag to each of its
React Query hooks. From AttendanceTab.tsx:
const { data = [], isLoading, isError } = useHrAttendanceMonthly(months, active);
And the hook in useReports.ts — the flag lands as React Query's enabled option:
export const useHrAttendanceMonthly = (months = 12, enabled = true) =>
useQuery({
queryKey: ['reports', 'hr-analytics', 'attendance-monthly', months],
queryFn: () => getHrAttendanceMonthly(months),
enabled,
});
A disabled query never fires: a tab you have not opened issues no requests. You might object
that lazyMount already guarantees this — an unmounted component runs no hooks at all. True, and
that is the point: the mount strategy and the request gate enforce the same policy at two
independent layers. If someone later removes lazyMount (or swaps the Tabs component), hidden tabs
still cost the backend nothing, because the data layer never trusted the mount layer to begin with.
Where the numbers come from
Every figure on the page arrives through the two-file pattern from
lesson 04: a thin wrapper in api/reports.ts, a hook in
hooks/api/useReports.ts. The HR endpoints live under /api/reports/hr-analytics/* — summary,
workforce, attendance-monthly, headcount-movement, leave, leave-monthly, and
overtime-by-department. Two wrappers show the whole shape:
export const getHrAttendanceMonthly = async (months = 12): Promise<HrAttendanceMonthlyDto[]> => {
const { data } = await apiClient.get<HrAttendanceMonthlyDto[]>('/api/reports/hr-analytics/attendance-monthly', { params: { months } });
return data;
};
export const getHrHeadcountMovement = async (months = 12, department?: string | null): Promise<HrHeadcountMovementDto[]> => {
const { data } = await apiClient.get<HrHeadcountMovementDto[]>('/api/reports/hr-analytics/headcount-movement', {
params: { months, ...(department ? { department } : {}) },
});
return data;
};
headcount-movement is the one that also accepts department — it powers the charts that re-scope
when the ?dept= chip is set. Two tabs also reuse pre-redesign endpoints (/api/reports/leave-by-status
and /api/reports/overtime-monthly): the redesign rebuilt the presentation, not every query behind
it. The eighth endpoint, /api/reports/hr-analytics/drilldown, feeds the drawer that opens when you
click a chart element — lesson 18 owns it.
The before-picture
Three analytics pages predate this redesign and still run on the older generation:
CrmAnalytics.tsx defines its own StatCard and ChartCard inline over raw Recharts with a
hard-coded PIE_COLORS hex array; PayrollAnalytics.tsx imports StatCard/ChartCard from
components/dashboard/shared/; RecruitmentAnalytics.tsx composes Recharts directly. From
CrmAnalytics.tsx:
const PIE_COLORS = ['#3B82F6', '#06B6D4', '#8B5CF6', '#F59E0B', '#10B981', '#EF4444', '#F97316', '#14B8A6'];
Nothing here is broken — but no shared tokens, no active-gated queries, no drill-downs. The
AnalyticsPanel, KpiTile, and chart wrappers you meet in lessons 15
through 17 are the redesigned generation; these three pages are the
migration backlog, and reading them side by side with OverviewTab.tsx shows exactly what the
component system buys.
Where this shows up in MotorPH
- frontend/src/pages/hr/HrAnalytics.tsx — the page you just read whole
- frontend/src/pages/hr/hr-analytics/OverviewTab.tsx — the densest of the five tabs
- frontend/src/pages/hr/hr-analytics/AttendanceTab.tsx — the
active→enabledchain quoted above - frontend/src/hooks/api/useReports.ts — every report hook, one screen
- frontend/src/api/reports.ts — the
/api/reports/hr-analytics/*wrappers - frontend/src/components/analytics/useHrAnalyticsFilters.ts —
?months=/?dept=ownership, decoded in lesson 19 - frontend/src/pages/crm/CrmAnalytics.tsx — the before-picture
- the Frontend reference and ../frontend/state.md — the conventions this page instantiates
Recap
lazyMount unmountOnExitis a Recharts fix, not an optimization —ResponsiveContainermeasures a hidden panel as width 0 and draws an empty chart; mounting on first visit guarantees a real width, and unmounting on exit makes every return trip take the same safe path.- Every tab gets
active, and every hook takes it asenabled— hidden tabs issue no requests, even if the mount strategy changes, because the data layer never trusts the mount layer. - Tab choice is local state;
?months=,?dept=, and?drill=are URL-backed viauseHrAnalyticsFilters— linkable state goes in the URL, navigation ephemera stays in React. - All numbers flow through
api/reports.ts→useReports.tsfrom/api/reports/hr-analytics/*— one wrapper, one hook, one query key per chart, so caching and refetching stay predictable. - The page orchestrates; the tabs and the component library do the work — which is why five tabs, 3D, drill-downs, and PDF export fit behind a ~126-line component.