Rapport-Mobileapp/src/pages/MetricReports.jsx

1677 lines
53 KiB
JavaScript
Executable File

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Container,
Card,
Button,
Form,
Modal,
Spinner,
} from "react-bootstrap";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import PageComponent from "../components/PageComponent/PageComponent";
import CustomIcon from "../components/CustomIcon/CustomIcon";
import SectionHeading from "../components/UI/SectionHeading/SectionHeading";
import metricReportService from "../services/MetricReport";
import authService from "../services/Auth";
import MetricReportDataTable from "../components/MetricReports/MetricReportDataTable";
import MetricReportValuePanel from "../components/MetricReports/MetricReportValuePanel";
import { useLinePathDraw } from "../components/MetricReports/metricChartDraw";
import {
getTrendChartLayout,
trendXLabelAnchor,
} from "../components/MetricReports/metricTrendChartLayout";
import "../components/MetricReports/metricChartAnimations.css";
import {
buildMetricExportFileBase,
exportRowsCsv,
} from "../components/MetricReports/metricReportExportUtil";
import LeaderboardExportModal from "../components/MetricReports/LeaderboardExportModal";
import {
buildLeaderboardAdditionalFieldOptions,
buildLeaderboardExportColumns,
buildMetricLeaderboardExportColumns,
getMetricLeaderboardExportFromApi,
LEADERBOARD_EXPORT_FORM_DEFAULT,
prepareLeaderboardExportRows,
prepareLeaderboardExportRowsFromRaw,
} from "../components/MetricReports/leaderboardExportUtil";
import {
enrichMetricLeaderboardRow,
enrichMetricLeaderboardTableExportRow,
getLeaderboardTabularConfig,
leaderboardConfigToExportColumns,
mapLeaderboardRowsFromConfig,
normalizeLeaderboardTabularConfig,
} from "../components/MetricReports/leaderboardTabularUtil";
import {
exportHtmlElementAsPng,
exportTableAsPng,
LEADERBOARD_PNG_MAX_ROWS,
} from "../components/MetricReports/exportTableAsPng";
import "./MetricReports.css";
import { handleAlert } from "../util/util";
function isDoctorListTabName(tab) {
if (!tab || typeof tab !== "string") return false;
const t = tab.trim().toLowerCase().replace(/\s+/g, " ");
if (t === "doctor list" || t === "doctorlist") return true;
return t.includes("doctor") && t.includes("list");
}
function isDetailOrDoctorListTab(tab) {
if (!tab || typeof tab !== "string") return false;
const t = tab.trim().toLowerCase().replace(/\s+/g, " ");
if (t === "detail") return true;
return isDoctorListTabName(tab);
}
/** Freeze first column only on wide tabular tables (Detail, Doctor List, Trend tabular). */
function tabularStickyLeadingCount(columns) {
const n = Array.isArray(columns) ? columns.length : 0;
return n > 3 ? 1 : 0;
}
/** Doctor List API: graph.values has contacts[], users { [contactId]: user }, metrics[] with stats { [contactId]: number } */
function isDoctorListValuesShape(values) {
if (!values || typeof values !== "object") return false;
if (!Array.isArray(values.contacts)) return false;
const u = values.users;
if (u == null || typeof u !== "object" || Array.isArray(u)) return false;
return true;
}
function titleCaseChart(c) {
if (!c) return "";
const s = String(c).toLowerCase();
if (s === "tabular") return "Tabular";
if (s === "line") return "Line";
if (s === "bar") return "Bar";
return s.charAt(0).toUpperCase() + s.slice(1);
}
function toNumber(v) {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
function normalizeTrendSeries(graph) {
// Supports both:
// - simple: graph.values = [{x, y}, ...]
// - grouped: graph.values = [{metric, stats: [{x, y}, ...]}, ...]
const values = graph?.values;
if (!values) return [];
if (Array.isArray(values)) {
const first = values[0];
if (first && typeof first === "object" && Array.isArray(first.stats)) {
return values.map((s) => ({
label: s?.metric || s?.label || "Series",
points: (s?.stats || []).map((p) => ({ x: p?.x, y: toNumber(p?.y) })),
}));
}
return [
{
label: graph?.label || graph?.name || "Trend",
points: values.map((p) => ({ x: p?.x, y: toNumber(p?.y) })),
},
];
}
return [];
}
function TrendLineSeriesPath({ d, color, delayMs }) {
const pathRef = useRef(null);
useLinePathDraw(pathRef, d);
return (
<path
ref={pathRef}
className="metric-line-path"
d={d}
fill="none"
stroke={color}
strokeWidth="2.5"
style={{ animationDelay: `${delayMs}ms` }}
/>
);
}
function TrendLineChart({ series }) {
const safeSeries = Array.isArray(series) ? series : [];
const pts = safeSeries?.[0]?.points || [];
const { width, height, padL, padT, padB, innerW, innerH, yLabelX } =
getTrendChartLayout();
const axisX = padL;
const ys = safeSeries.flatMap((s) => (s?.points || []).map((p) => p.y));
const minY = ys.length ? Math.min(...ys) : 0;
const maxY = ys.length ? Math.max(...ys) : 1;
const rangeY = maxY - minY || 1;
const toX = (i) =>
padL + (pts.length <= 1 ? innerW / 2 : (i / (pts.length - 1)) * innerW);
const toY = (y) => padT + innerH - ((y - minY) / rangeY) * innerH;
const COLORS = ["#00bcd4", "#4A5CD0", "#01C14B", "#FF8050", "#FECD00"];
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) => {
const t = i / yTicks;
return minY + (maxY - minY) * t;
});
const xLabelEvery = Math.max(1, Math.floor((pts.length - 1) / 4));
return (
<div className="metric-chart-draw" style={{ width: "100%" }}>
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height={height}
role="img"
aria-label="Trend line chart"
>
<rect x="0" y="0" width={width} height={height} fill="#fff" />
{/* axes */}
<line
x1={axisX}
y1={padT}
x2={axisX}
y2={padT + innerH}
stroke="#e5e7eb"
/>
<line
x1={axisX}
y1={padT + innerH}
x2={axisX + innerW}
y2={padT + innerH}
stroke="#e5e7eb"
/>
{/* y ticks */}
{yTickVals.map((v, idx) => {
const y = toY(v);
return (
<g key={idx}>
<line
x1={axisX}
y1={y}
x2={axisX + innerW}
y2={y}
stroke="#f1f5f9"
/>
<text
x={yLabelX}
y={y + 3}
textAnchor="end"
fontSize="8"
fill="#6b7280"
>
{Math.round(v)}
</text>
</g>
);
})}
{/* x ticks */}
{pts.map((p, i) => {
if (i !== 0 && i !== pts.length - 1 && i % xLabelEvery !== 0) return null;
const x = toX(i);
const labelAnchor = trendXLabelAnchor(i, pts.length);
return (
<g key={i}>
<line
x1={x}
y1={padT + innerH}
x2={x}
y2={padT + innerH + 4}
stroke="#e5e7eb"
/>
<text
x={x}
y={padT + innerH + 12}
textAnchor={labelAnchor}
fontSize="8"
fill="#6b7280"
>
{String(p?.x ?? "")}
</text>
</g>
);
})}
{safeSeries.map((s, sIdx) => {
const sPts = s?.points || [];
const d = sPts
.map((p, i) => `${i === 0 ? "M" : "L"} ${toX(i)} ${toY(p.y)}`)
.join(" ");
const color = COLORS[sIdx % COLORS.length];
const seriesDelay = sIdx * 80;
return (
<g key={s.label || sIdx}>
<TrendLineSeriesPath d={d} color={color} delayMs={seriesDelay} />
{sPts.map((p, i) => (
<circle
key={i}
className="metric-line-dot"
cx={toX(i)}
cy={toY(p.y)}
r="3.2"
fill={color}
style={{
animationDelay: `${seriesDelay + 180 + i * 35}ms`,
}}
/>
))}
</g>
);
})}
</svg>
{safeSeries.length > 1 ? (
<div className="d-flex flex-wrap gap-2 mt-2">
{safeSeries.map((s, idx) => (
<div key={s.label || idx} className="d-flex align-items-center gap-1">
<span
style={{
width: 10,
height: 10,
borderRadius: 999,
background: COLORS[idx % COLORS.length],
display: "inline-block",
}}
/>
<span className="text-muted x-small">{s.label}</span>
</div>
))}
</div>
) : null}
</div>
);
}
function TrendBarChart({ series }) {
const safeSeries = Array.isArray(series) ? series : [];
const pts = safeSeries?.[0]?.points || [];
const { width, height, padL, padT, padB, innerW, innerH, yLabelX } =
getTrendChartLayout();
const axisX = padL;
const ys = safeSeries.flatMap((s) => (s?.points || []).map((p) => p.y));
const maxY = ys.length ? Math.max(...ys) : 1;
const scaleY = innerH / (maxY || 1);
const seriesCount = Math.max(1, safeSeries.length);
const groupW = pts.length ? innerW / pts.length : innerW;
const barW = Math.max(4, (groupW - 6) / seriesCount);
const COLORS = ["#00bcd4", "#4A5CD0", "#01C14B", "#FF8050", "#FECD00"];
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) => (maxY * i) / yTicks);
const xLabelEvery = Math.max(1, Math.floor((pts.length - 1) / 4));
return (
<div className="metric-chart-draw" style={{ width: "100%" }}>
<svg
viewBox={`0 0 ${width} ${height}`}
width="100%"
height={height}
role="img"
aria-label="Trend bar chart"
>
<rect x="0" y="0" width={width} height={height} fill="#fff" />
{/* axes */}
<line
x1={axisX}
y1={padT}
x2={axisX}
y2={padT + innerH}
stroke="#e5e7eb"
/>
<line
x1={axisX}
y1={padT + innerH}
x2={axisX + innerW}
y2={padT + innerH}
stroke="#e5e7eb"
/>
{/* y ticks */}
{yTickVals.map((v, idx) => {
const y = padT + innerH - v * scaleY;
return (
<g key={idx}>
<line
x1={axisX}
y1={y}
x2={axisX + innerW}
y2={y}
stroke="#f1f5f9"
/>
<text
x={yLabelX}
y={y + 3}
textAnchor="end"
fontSize="8"
fill="#6b7280"
>
{Math.round(v)}
</text>
</g>
);
})}
{/* x ticks */}
{pts.map((p, i) => {
if (i !== 0 && i !== pts.length - 1 && i % xLabelEvery !== 0) return null;
const x = axisX + i * groupW + groupW / 2;
const labelAnchor = trendXLabelAnchor(i, pts.length);
return (
<g key={i}>
<line
x1={x}
y1={padT + innerH}
x2={x}
y2={padT + innerH + 4}
stroke="#e5e7eb"
/>
<text
x={x}
y={padT + innerH + 12}
textAnchor={labelAnchor}
fontSize="8"
fill="#6b7280"
>
{String(p?.x ?? "")}
</text>
</g>
);
})}
{pts.map((_, i) => (
<g key={i}>
{safeSeries.map((s, sIdx) => {
const p = s?.points?.[i];
const h = toNumber(p?.y) * scaleY;
const x = axisX + i * groupW + sIdx * barW;
const y = padT + innerH - h;
const color = COLORS[sIdx % COLORS.length];
const delayMs = i * 45 + sIdx * 25;
return (
<rect
key={s.label || sIdx}
className="metric-bar-rect"
x={x}
y={y}
width={barW}
height={h}
rx="2"
fill={color}
style={{ animationDelay: `${delayMs}ms` }}
/>
);
})}
</g>
))}
</svg>
{safeSeries.length > 1 ? (
<div className="d-flex flex-wrap gap-2 mt-2">
{safeSeries.map((s, idx) => (
<div key={s.label || idx} className="d-flex align-items-center gap-1">
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
background: COLORS[idx % COLORS.length],
display: "inline-block",
}}
/>
<span className="text-muted x-small">{s.label}</span>
</div>
))}
</div>
) : null}
</div>
);
}
const MOCK_TREND_ROWS = [
{ period: "Week 1", rx: 425, pob: 332, calls: 272, score: 2456 },
{ period: "Week 2", rx: 418, pob: 325, calls: 268, score: 2398 },
{ period: "Week 3", rx: 432, pob: 338, calls: 276, score: 2489 },
{ period: "Week 4", rx: 420, pob: 326, calls: 272, score: 2421 },
];
const MOCK_PEOPLE = [
{
rank: 1,
name: "Deepak Sharma",
code: "EMP001",
rx: 245,
pob: 189,
calls: 156,
productivity: 94.5,
score: 982,
},
{
rank: 2,
name: "Dummy CHANDIGARH ZSM",
code: "EMP002",
rx: 238,
pob: 182,
calls: 148,
productivity: 92.3,
score: 956,
},
{
rank: 3,
name: "DURGA NANENDLA",
code: "EMP003",
rx: 221,
pob: 175,
calls: 142,
productivity: 89.7,
score: 924,
},
{
rank: 4,
name: "MOHAMMED M",
code: "EMP004",
rx: 215,
pob: 168,
calls: 138,
productivity: 87.2,
score: 901,
},
{
rank: 5,
name: "T Ramana Babu",
code: "EMP005",
rx: 208,
pob: 162,
calls: 134,
productivity: 85.6,
score: 886,
},
{
rank: 6,
name: "Rajesh Kumar",
code: "EMP006",
rx: 198,
pob: 155,
calls: 128,
productivity: 82.4,
score: 862,
},
{
rank: 7,
name: "Priya Singh",
code: "EMP007",
rx: 192,
pob: 148,
calls: 124,
productivity: 80.1,
score: 845,
},
{
rank: 8,
name: "Amit Patel",
code: "EMP008",
rx: 186,
pob: 142,
calls: 118,
productivity: 78.5,
score: 827,
},
];
function RankBadge({ rank }) {
const cls =
rank === 1
? "metric-rank-badge metric-rank-badge--gold"
: rank === 2
? "metric-rank-badge metric-rank-badge--silver"
: rank === 3
? "metric-rank-badge metric-rank-badge--bronze"
: "metric-rank-badge metric-rank-badge--muted";
return <span className={cls}>{rank}</span>;
}
function MetricReports() {
const navigate = useNavigate();
const location = useLocation();
const { metricViewId: metricViewIdParam } = useParams();
const metricViewId = metricViewIdParam || location?.state?.metricViewId;
const scoreboardMetricViewId = location?.state?.scoreboardMetricViewId;
const teamMetricViewId = location?.state?.teamMetricViewId;
const [showMenu, setShowMenu] = useState(false);
const [showExport, setShowExport] = useState(false);
const valueChartExportRef = useRef(null);
const trendChartExportRef = useRef(null);
const initialTab = (() => {
const tab = location?.state?.defaultTab;
return typeof tab === "string" && tab.length ? tab : "Detail";
})();
const [activeTab, setActiveTab] = useState(initialTab);
const [trendSubTab, setTrendSubTab] = useState(
initialTab === "Trend" ? "line" : "line"
);
const [selectedLevel, setSelectedLevel] = useState(1);
const [levelTouched, setLevelTouched] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState("");
const [availableCharts, setAvailableCharts] = useState([]);
const [periodOptions, setPeriodOptions] = useState([]);
const [post, setPost] = useState(() => ({
id: metricViewId,
...(initialTab === "Trend"
? { type: "trend", chart: "line" }
: initialTab === "Value"
? { type: "value", chart: "value" }
: initialTab === "Leaderboard"
? { type: "scoreboard", chart: "leaderboard" }
: isDoctorListTabName(initialTab)
? { type: "contacts", chart: "contacts" }
: { type: "details", chart: "tabular" }),
}));
const [exportForm, setExportForm] = useState({
...LEADERBOARD_EXPORT_FORM_DEFAULT,
});
const chartAnimationKey = useMemo(() => {
const parts = [
metricViewId || "",
activeTab || "",
post?.type || "",
post?.chart || "",
post?.period || "",
post?.level ?? "",
// Trend sub-tab is used for chart choices inside Trend tab
activeTab === "Trend" ? trendSubTab : "",
];
return parts.join("|");
}, [
metricViewId,
activeTab,
post?.type,
post?.chart,
post?.period,
post?.level,
trendSubTab,
]);
useEffect(() => {
if (!metricViewId) {
navigate("/metric-reports", { replace: true });
return;
}
setPost((p) => ({ ...p, id: metricViewId }));
}, [metricViewId, navigate]);
useEffect(() => {
if (!metricViewId) return;
const tab = location?.state?.defaultTab;
if (typeof tab !== "string" || !tab.length) return;
setActiveTab(tab);
if (tab === "Trend") {
setTrendSubTab("line");
setPost((p) => ({
...p,
id: metricViewId,
type: "trend",
chart: "line",
}));
} else if (tab === "Value") {
setPost((p) => ({
...p,
id: metricViewId,
type: "value",
chart: "value",
}));
} else if (tab === "Leaderboard") {
setPost((p) => ({
...p,
id: metricViewId,
type: "scoreboard",
chart: "leaderboard",
}));
} else if (isDoctorListTabName(tab)) {
setPost((p) => ({
...p,
id: metricViewId,
type: "contacts",
chart: "contacts",
}));
} else {
setPost((p) => ({
...p,
id: metricViewId,
type: "details",
chart: "tabular",
}));
}
}, [metricViewId, location?.state?.defaultTab]);
const visibleTabs = useMemo(() => {
const tabs = (availableCharts || [])
.filter((t) => Array.isArray(t?.charts) && t.charts.length > 0)
.map((t) => ({ name: t.name, type: t.type, charts: t.charts }));
if (tabs.length > 0) return tabs;
return [
{ name: "Detail", type: "details", charts: ["tabular"] },
{ name: "Leaderboard", type: "scoreboard", charts: ["leaderboard"] },
];
}, [availableCharts]);
const trendChartOptions = useMemo(() => {
const trend = visibleTabs.find((t) => t.type === "trend");
const charts = Array.isArray(trend?.charts) ? trend.charts : [];
return charts.map((c) => ({
value: String(c).toLowerCase(),
label: titleCaseChart(c),
}));
}, [visibleTabs]);
const showLevelRow =
activeTab === "My Team" || activeTab === "Leaderboard";
const {
data: viewRes,
isFetching: isViewFetching,
isError: isViewError,
} = useQuery({
queryKey: ["metricReportView", post],
enabled: !!post?.id && !!post?.type && !!post?.chart,
queryFn: async () => {
const res = await metricReportService.postReportView(post);
return res?.data;
},
});
useEffect(() => {
const periods = viewRes?.data?.periods;
const charts = viewRes?.data?.available_charts;
const serverPost = viewRes?.data?.post;
const graph = viewRes?.data?.graph;
if (Array.isArray(periods)) setPeriodOptions(periods);
if (Array.isArray(charts)) setAvailableCharts(charts);
if (serverPost && typeof serverPost === "object") {
setPost((p) => ({ ...p, ...serverPost, id: p?.id ?? serverPost?.id }));
if (!selectedPeriod && serverPost?.period) {
setSelectedPeriod(serverPost.period);
}
if (serverPost?.type === "trend" && serverPost?.chart) {
setTrendSubTab(String(serverPost.chart).toLowerCase());
}
}
if (graph && typeof graph === "object") {
const currentLevel =
typeof graph.current_level === "number"
? graph.current_level
: typeof graph.level === "number"
? graph.level
: null;
if (typeof currentLevel === "number") {
setSelectedLevel(currentLevel);
}
}
}, [viewRes, selectedPeriod]);
const maxLevel =
typeof viewRes?.data?.graph?.level === "number" ? viewRes.data.graph.level : 0;
const levelButtons = useMemo(() => {
if (!showLevelRow || !maxLevel || maxLevel < 1) return [];
return Array.from({ length: maxLevel }, (_, i) => maxLevel - i);
}, [showLevelRow, maxLevel]);
useEffect(() => {
if (!showLevelRow) return;
if (!levelTouched) {
setPost((p) => {
if (!p || typeof p !== "object") return p;
if (!("level" in p)) return p;
const { level, ...rest } = p;
return rest;
});
}
}, [showLevelRow, levelTouched]);
const detailValues = viewRes?.data?.graph?.values;
const detailUsers = Array.isArray(detailValues?.users)
? detailValues.users
: [];
const detailMetrics = detailValues?.metrics || [];
const isDoctorListTabActive =
isDoctorListTabName(activeTab) || post?.type === "contacts";
const doctorListValuesShape = isDoctorListValuesShape(detailValues);
const leaderboardRows = Array.isArray(viewRes?.data?.graph?.values)
? viewRes.data.graph.values
: [];
const leaderboardMetrics = Array.isArray(viewRes?.data?.graph?.metrics)
? viewRes.data.graph.metrics
: [];
const leaderboardTabularColumns = useMemo(
() =>
normalizeLeaderboardTabularConfig(
getLeaderboardTabularConfig(viewRes?.data)
),
[viewRes?.data]
);
const usesLeaderboardTabularConfig = leaderboardTabularColumns.length > 0;
const leaderboardExportParsed = useMemo(
() => getMetricLeaderboardExportFromApi(viewRes?.data),
[viewRes?.data]
);
const usesLeaderboardExportConfig =
leaderboardExportParsed.allColumns.length > 0;
const leaderboardAdditionalFieldOptions = useMemo(() => {
if (usesLeaderboardExportConfig) {
return leaderboardExportParsed.additionalOptions;
}
if (usesLeaderboardTabularConfig) {
return [];
}
return buildLeaderboardAdditionalFieldOptions(leaderboardMetrics);
}, [
usesLeaderboardExportConfig,
usesLeaderboardTabularConfig,
leaderboardExportParsed.additionalOptions,
leaderboardMetrics,
]);
const defaultExportAdditionalFieldIds = useMemo(
() => leaderboardAdditionalFieldOptions.map((f) => f.id),
[leaderboardAdditionalFieldOptions]
);
const showExportPrevRef = useRef(false);
useEffect(() => {
const justOpened = showExport && !showExportPrevRef.current;
showExportPrevRef.current = showExport;
if (!justOpened || !leaderboardAdditionalFieldOptions.length) return;
setExportForm((prev) => ({
...prev,
additionalFieldIds: defaultExportAdditionalFieldIds,
}));
}, [
showExport,
defaultExportAdditionalFieldIds,
leaderboardAdditionalFieldOptions.length,
]);
const metricTitle = useMemo(() => {
const fromNav =
location?.state?.reportTitle || location?.state?.pageTitle || "";
const fromApi = viewRes?.data?.graph?.name || "";
return String(fromNav || fromApi).trim();
}, [
location?.state?.reportTitle,
location?.state?.pageTitle,
viewRes?.data?.graph?.name,
]);
const trendGraphSeries = useMemo(() => {
if (activeTab !== "Trend") return [];
const graph = viewRes?.data?.graph;
return normalizeTrendSeries(graph);
}, [activeTab, viewRes?.data?.graph]);
const trendTabularRows = useMemo(() => {
const series = trendGraphSeries;
if (!Array.isArray(series) || series.length === 0) return [];
const pts = series[0]?.points || [];
return pts.map((p, idx) => {
const row = { period: p?.x ?? "-" };
series.forEach((s, sIdx) => {
row[`s_${sIdx}`] = s?.points?.[idx]?.y ?? 0;
});
return row;
});
}, [trendGraphSeries]);
const trendTabularColumns = useMemo(() => {
const series = trendGraphSeries;
const cols = [
{
accessorKey: "period",
header: "Period",
},
];
series.forEach((s, sIdx) => {
cols.push({
accessorKey: `s_${sIdx}`,
header: s?.label || `Series ${sIdx + 1}`,
cell: (info) => (
<span className="fw-semibold">{info.getValue()}</span>
),
});
});
return cols;
}, [trendGraphSeries]);
const doctorListTableRows = useMemo(() => {
if (!doctorListValuesShape || !detailValues) return [];
const contacts = detailValues.contacts || [];
const usersMap = detailValues.users || {};
const metricsArr = Array.isArray(detailValues.metrics)
? detailValues.metrics
: [];
return contacts.map((c) => {
const contactId = c?.id;
const u = contactId ? usersMap[contactId] : null;
const row = {
doctor: c?.name ?? "-",
msl_code: c?.msl_code ?? "-",
user_name: u?.name ?? "-",
employee_code: u?.employee_code ?? "-",
zone: u?.positions?.Zone ?? "-",
region: u?.positions?.Region ?? "-",
area: u?.positions?.Area ?? "-",
territory: u?.positions?.Territory ?? "-",
};
metricsArr.forEach((m) => {
const mid = m?.metric_id ?? m?.metric;
if (mid == null) return;
const raw =
m?.stats && contactId != null && m.stats[contactId] !== undefined
? m.stats[contactId]
: "";
row[`metric_${mid}`] = raw === "" || raw == null ? "-" : raw;
});
return row;
});
}, [doctorListValuesShape, detailValues]);
const graphMetricIdToDisplay = useMemo(() => {
const gm = viewRes?.data?.graph?.metrics;
if (!Array.isArray(gm)) return {};
const out = {};
gm.forEach((x) => {
const id = x?.metric;
if (id) out[id] = x?.display || String(id);
});
return out;
}, [viewRes?.data?.graph?.metrics]);
const doctorListTableColumns = useMemo(() => {
const base = [
{ accessorKey: "doctor", header: "Doctor" },
{ accessorKey: "msl_code", header: "MSL Code" },
{ accessorKey: "user_name", header: "User" },
{ accessorKey: "employee_code", header: "Employee Code" },
{ accessorKey: "zone", header: "Zone" },
{ accessorKey: "region", header: "Region" },
{ accessorKey: "area", header: "Area" },
{ accessorKey: "territory", header: "Territory" },
];
const metricsArr =
doctorListValuesShape && detailValues?.metrics
? detailValues.metrics
: [];
const dyn = (Array.isArray(metricsArr) ? metricsArr : []).map((m) => {
const mid = m?.metric_id ?? m?.metric;
const fromGraph = mid != null ? graphMetricIdToDisplay[mid] : null;
const headerLabel =
fromGraph ||
(typeof m?.metric === "string" && m.metric.trim() ? m.metric : null) ||
m?.display ||
mid;
return {
accessorKey: `metric_${mid}`,
header: headerLabel,
};
});
return [...base, ...dyn];
}, [
doctorListValuesShape,
detailValues,
graphMetricIdToDisplay,
]);
const detailTableRows = useMemo(() => {
return detailUsers.map((u) => {
const row = {
name: [u?.name, u?.designation ? ` (${u.designation})` : ""]
.filter(Boolean)
.join(""),
employee_code: u?.employee_code ?? "-",
zone: u?.positions?.Zone ?? "-",
region: u?.positions?.Region ?? "-",
area: u?.positions?.Area ?? "-",
territory: u?.positions?.Territory ?? "-",
role: u?.role ?? u?.roles ?? "-",
};
detailMetrics.forEach((m) => {
const mk = m?.metric ?? m?.metric_id;
if (mk == null) return;
row[`metric_${mk}`] =
m?.stats && u?.id != null ? m.stats[u.id] ?? 0 : 0;
});
return row;
});
}, [detailUsers, detailMetrics]);
const detailTableColumns = useMemo(() => {
const base = [
{ accessorKey: "name", header: "User" },
{ accessorKey: "employee_code", header: "Employee Code" },
{ accessorKey: "zone", header: "Zone" },
{ accessorKey: "region", header: "Region" },
{ accessorKey: "area", header: "Area" },
{ accessorKey: "territory", header: "Territory" },
{ accessorKey: "role", header: "Role" },
];
const dyn = detailMetrics.map((m) => ({
accessorKey: `metric_${m?.metric ?? m?.metric_id}`,
header: m?.display || m?.metric,
}));
return [...base, ...dyn];
}, [detailMetrics]);
const authUser = useMemo(() => {
try {
const raw = authService.getAuthUser();
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}, []);
const isCurrentUserRow = useCallback(
(row) => {
if (!row || !authUser) return false;
const authId = authUser.id ?? authUser.user_id;
if (authId != null && row.user_id != null) {
return String(row.user_id) === String(authId);
}
const authCode = authUser.employee_code;
const rowCode = row.employee_code ?? row.user_employee_code;
if (
authCode != null &&
String(authCode).trim() !== "" &&
rowCode != null &&
rowCode !== "-"
) {
return String(rowCode) === String(authCode);
}
return false;
},
[authUser]
);
const leaderboardTableRows = useMemo(() => {
if (usesLeaderboardTabularConfig) {
return mapLeaderboardRowsFromConfig(
leaderboardRows,
leaderboardTabularColumns,
{ enrichRow: enrichMetricLeaderboardRow }
);
}
return Array.from(leaderboardRows)
.filter((row) => row != null && typeof row === "object")
.map((row) => {
const u = row?.user || {};
const out = {
rank: row?.rank ?? 0,
user_id: u?.id ?? u?.user_id ?? row?.user_id ?? null,
name: [u?.name, u?.designation ? ` (${u.designation})` : ""]
.filter(Boolean)
.join(""),
employee_code: u?.employee_code ?? "-",
zone: u?.positions?.Zone ?? "-",
region: u?.positions?.Region ?? "-",
area: u?.positions?.Area ?? "-",
territory: u?.positions?.Territory ?? "-",
role: u?.role ?? u?.roles ?? "-",
units:
typeof row?.score === "number"
? Math.round(row.score * 100) / 100
: row?.score ?? 0,
};
leaderboardMetrics.forEach((m) => {
const mk = m?.metric;
if (mk == null) return;
out[`m_${mk}`] = row?.stats && mk in row.stats ? row.stats[mk] : 0;
});
return out;
})
.sort((a, b) => Number(a.rank) - Number(b.rank));
}, [
usesLeaderboardTabularConfig,
leaderboardRows,
leaderboardTabularColumns,
leaderboardMetrics,
]);
const leaderboardTableColumns = useMemo(() => {
if (usesLeaderboardTabularConfig) {
return leaderboardTabularColumns.map((col, index) => {
const column = {
id: col.accessorKey,
accessorKey: col.accessorKey,
header: col.label,
};
if (index < 2) {
column.meta = { sticky: true };
}
if (col.isRank) {
column.cell = (info) => (
<RankBadge rank={Number(info.getValue()) || 0} />
);
}
return column;
});
}
return [
{
id: "rank",
accessorKey: "rank",
header: "Rank",
meta: { sticky: true },
cell: (info) => (
<RankBadge rank={Number(info.getValue()) || 0} />
),
},
{ accessorKey: "name", header: "User" },
{ accessorKey: "employee_code", header: "Employee Code" },
{ accessorKey: "zone", header: "Zone" },
{ accessorKey: "region", header: "Region" },
{ accessorKey: "area", header: "Area" },
{ accessorKey: "territory", header: "Territory" },
{ accessorKey: "role", header: "Role" },
...leaderboardMetrics.map((m) => ({
accessorKey: `m_${m?.metric}`,
header: m?.display || m?.metric,
})),
{ accessorKey: "units", header: "Units" },
];
}, [usesLeaderboardTabularConfig, leaderboardTabularColumns, leaderboardMetrics]);
const leaderboardConfigExportColumns = useMemo(
() => leaderboardConfigToExportColumns(leaderboardTabularColumns),
[leaderboardTabularColumns]
);
const metricExportFileBase = useMemo(
() =>
buildMetricExportFileBase({
metricName: metricTitle,
tabName: activeTab,
trendSubTab: activeTab === "Trend" ? trendSubTab : undefined,
useDoctorList:
isDetailOrDoctorListTab(activeTab) &&
isDoctorListTabActive &&
!!doctorListValuesShape,
}),
[
metricTitle,
activeTab,
trendSubTab,
isDoctorListTabActive,
doctorListValuesShape,
]
);
function openLeaderboardExportModal() {
setExportForm({
...LEADERBOARD_EXPORT_FORM_DEFAULT,
additionalFieldIds: defaultExportAdditionalFieldIds,
format: "csv",
});
setShowExport(true);
}
async function handlePeriodHeaderDownload() {
if (activeTab === "Leaderboard") {
openLeaderboardExportModal();
return;
}
if (activeTab === "Trend") {
if (trendSubTab === "tabular") {
if (!trendTabularRows.length) {
handleAlert("No data to export.", "info");
return;
}
try {
await exportRowsCsv(
trendTabularRows,
trendTabularColumns,
metricExportFileBase
);
} catch {
handleAlert("Export failed.", "info");
}
return;
}
if (trendSubTab === "line" || trendSubTab === "bar") {
const el = trendChartExportRef.current;
if (!el) {
handleAlert("Unable to capture chart.", "info");
return;
}
try {
await exportHtmlElementAsPng(el, metricExportFileBase);
} catch {
handleAlert("Could not export PNG.", "info");
}
return;
}
handleAlert("Choose Line or Bar to export as PNG.", "info");
return;
}
if (activeTab === "Value") {
const graph = viewRes?.data?.graph;
const slices = Array.isArray(graph?.values) ? graph.values : [];
if (!slices.length) {
handleAlert("No data to export.", "info");
return;
}
const el = valueChartExportRef.current;
if (!el) {
handleAlert("Unable to capture chart.", "info");
return;
}
try {
await exportHtmlElementAsPng(el, metricExportFileBase);
} catch {
handleAlert("Could not export PNG.", "info");
}
return;
}
if (isDetailOrDoctorListTab(activeTab)) {
if (isDoctorListTabActive && doctorListValuesShape) {
if (!doctorListTableRows.length) {
handleAlert("No data to export.", "info");
return;
}
try {
await exportRowsCsv(
doctorListTableRows,
doctorListTableColumns,
metricExportFileBase
);
} catch {
handleAlert("Export failed.", "info");
}
return;
}
if (!isDoctorListTabActive) {
if (!detailTableRows.length) {
handleAlert("No data to export.", "info");
return;
}
try {
await exportRowsCsv(
detailTableRows,
detailTableColumns,
metricExportFileBase
);
} catch {
handleAlert("Export failed.", "info");
}
return;
}
handleAlert("No doctor list data to export.", "info");
return;
}
if (activeTab === "My Team") {
if (!leaderboardTableRows.length) {
handleAlert("No data to export.", "info");
return;
}
try {
await exportRowsCsv(
leaderboardTableRows,
leaderboardTableColumns,
metricExportFileBase
);
} catch {
handleAlert("Export failed.", "info");
}
return;
}
handleAlert("Nothing to export for this tab.", "info");
}
async function confirmLeaderboardExportDownload() {
if (!leaderboardRows.length) {
handleAlert("No leaderboard data to export.", "info");
return;
}
let cols;
let exportRows;
if (usesLeaderboardExportConfig) {
const selectedAdditionalIds = (exportForm.additionalFieldIds || []).filter(
(id) =>
leaderboardExportParsed.additionalOptions.some((o) => o.id === id)
);
cols = buildMetricLeaderboardExportColumns(
selectedAdditionalIds,
leaderboardExportParsed
);
exportRows = prepareLeaderboardExportRowsFromRaw(
leaderboardRows,
cols,
{
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichMetricLeaderboardRow,
}
);
} else if (usesLeaderboardTabularConfig) {
cols = leaderboardConfigExportColumns;
exportRows = prepareLeaderboardExportRows(
leaderboardTableRows,
cols,
{
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichMetricLeaderboardTableExportRow,
}
);
} else {
const selectedAdditionalIds = (exportForm.additionalFieldIds || []).filter(
(id) => leaderboardAdditionalFieldOptions.some((o) => o.id === id)
);
cols = buildLeaderboardExportColumns(
selectedAdditionalIds,
leaderboardMetrics
);
exportRows = prepareLeaderboardExportRows(
leaderboardTableRows,
cols,
{
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichMetricLeaderboardTableExportRow,
}
);
}
try {
if (exportForm.format === "png") {
await exportTableAsPng(exportRows, cols, metricExportFileBase, {
useLeaderboardTableStyles: true,
highlightRow: isCurrentUserRow,
});
} else {
await exportRowsCsv(exportRows, cols, metricExportFileBase);
}
setShowExport(false);
} catch {
handleAlert("Export failed.", "info");
}
}
return (
<PageComponent className="metric-report-page">
<div className="metric-report-shell">
<Container fluid className="metric-inner pt-1 pb-5">
<div className="metric-menu-anchor-abs">
<Button
size="sm"
className="metric-menu-trigger btn-theme btn-transparent d-flex align-items-center justify-content-center px-2"
aria-label="More actions"
aria-expanded={showMenu}
onClick={() => setShowMenu((v) => !v)}
>
<CustomIcon tag="sort7" className="ms-1 font-20 text-white" />
</Button>
{showMenu ? (
<>
<button
type="button"
className="metric-overlay-backdrop"
aria-label="Close menu"
onClick={() => setShowMenu(false)}
/>
<div className="metric-flyout-menu shadow-lg">
<button
type="button"
className="metric-flyout-item"
onClick={() => {setShowMenu(false); handleAlert('Currently under development...', 'info'); }}
>
<CustomIcon tag="message5" className="font-18 text-gray6" />
<span className="text-secondary font-13">Send me Email</span>
</button>
{/* <button
type="button"
className="metric-flyout-item border-0"
onClick={() => {
setShowMenu(false);
openLeaderboardExportModal();
}}
>
<CustomIcon tag="download1" className="font-18 text-gray6" />
<span className="text-secondary font-13">Export Leaderboard</span>
</button> */}
</div>
</>
) : null}
</div>
{metricTitle ? (
<div className="metric-details-heading px-0 pt-1 pb-2 mb-1">
<SectionHeading size="" className="mb-0 fw-sb font-14">
{metricTitle}
</SectionHeading>
</div>
) : null}
<div className="metric-period-header mb-1">
<div className="d-flex align-items-center">
<SectionHeading size="xs" className="mb-1 fw-n flex-grow-1">
<CustomIcon tag="date4" className="font-16 me-1" />
<span classname="ms-1" style={{ fontSize: '10px' }}>{`${isViewFetching ? 'Fetching...' : viewRes?.data?.graph?.first_day + ' to ' + viewRes?.data?.graph?.last_day}`}</span>
</SectionHeading>
<div className="flex-shrink-0 me-2" style={{ minWidth: 90 }}>
<Form.Select
size="sm"
className="metric-period-select"
value={selectedPeriod}
onChange={(e) => {
const period = e.target.value;
setSelectedPeriod(period);
setPost((p) => ({ ...p, period }));
}}
>
{periodOptions.length === 0 ? (
<option value="">Loading…</option>
) : (
periodOptions.map((p, idx) => (
<option key={`${p?.value}-${idx}`} value={p?.value}>
{p?.label}
</option>
))
)}
</Form.Select>
</div>
<div className="flex-shrink-0">
<Button
type="button"
onClick={() => {
void handlePeriodHeaderDownload();
}}
className="btn-theme btn-badges font-12 btn-download-report py-1 px-2 rounded-1"
aria-label="Download report"
>
<CustomIcon tag="download1" className="font-13" />
</Button>
</div>
</div>
</div>
<div className="metric-tab-row mb-0">
{visibleTabs.map((tab) => (
<button
key={tab.type}
type="button"
className={`metric-tab-btn ${
tab.name === activeTab ? "active" : ""
}`}
onClick={() => {
setActiveTab(tab.name);
setLevelTouched(false);
setPost((p) => {
const nextId =
tab.type === "scoreboard"
? scoreboardMetricViewId ?? p.id
: tab.type === "leaderboard"
? teamMetricViewId ?? p.id
: p.id;
if (tab.type === "value") {
return { ...p, id: nextId, type: "value", chart: "value" };
}
if (tab.type === "trend") {
return { ...p, id: nextId, type: "trend", chart: tab?.charts?.[0] || "line" };
}
return {
...p,
id: nextId,
type: tab.type,
chart: tab?.charts?.[0] || p.chart,
};
});
}}
>
{tab.name}
</button>
))}
</div>
{showLevelRow ? (
<div className="d-flex align-items-center gap-3 pt-2 pb-1">
<span className="small fw-medium text-secondary">Level</span>
<div className="d-flex gap-2">
{levelButtons.map((lvl) => (
<button
key={lvl}
type="button"
className={`metric-level-pill btn p-0 ${
selectedLevel === lvl ? "metric-level-pill--on" : "btn-light text-secondary"
}`}
onClick={() => {
setLevelTouched(true);
setSelectedLevel(lvl);
setPost((p) => ({ ...p, level: lvl }));
}}
>
{lvl}
</button>
))}
</div>
</div>
) : null}
<Card className="border-0 shadow-sm metric-content-card mt-2">
<Card.Body className="p-0">
{activeTab === "Value" && (
<div className="p-3 p-sm-4">
<MetricReportValuePanel
key={chartAnimationKey}
graph={viewRes?.data?.graph}
isLoading={isViewFetching}
isError={isViewError}
missingId={!metricViewId}
captureRef={valueChartExportRef}
className="metric-chart-enter"
/>
</div>
)}
{activeTab === "Trend" && (
<div>
<div className="metric-subtab-row border-bottom">
{trendChartOptions.map((sub) => (
<button
key={sub.value}
type="button"
className={`metric-subtab-btn ${
sub.value === trendSubTab ? "active" : ""
}`}
onClick={() => {
setTrendSubTab(sub.value);
setPost((p) => ({
...p,
type: "trend",
chart: sub.value,
}));
}}
>
{sub.label}
</button>
))}
</div>
<div className="p-0 px-1">
{!metricViewId ? (
<div className="text-center text-muted small py-4">
Missing metric id.
</div>
) : isViewError ? (
<div className="text-center text-danger small py-4">
Failed to load trend.
</div>
) : isViewFetching ? (
<div className="text-center text-muted small py-4">
<Spinner animation="border" variant="secondary" size="sm" /> <span className="ms-1 font-14">Loading trend..</span>
</div>
) : (() => {
const graph = viewRes?.data?.graph;
const series = normalizeTrendSeries(graph);
if (trendSubTab === "line") {
return (
<div
key={chartAnimationKey}
ref={trendChartExportRef}
className="metric-trend-png-capture bg-white metric-chart-enter"
>
<TrendLineChart series={series} />
</div>
);
}
if (trendSubTab === "bar") {
return (
<div
key={chartAnimationKey}
ref={trendChartExportRef}
className="metric-trend-png-capture bg-white metric-chart-enter"
>
<TrendBarChart series={series} />
</div>
);
}
if (trendSubTab === "tabular") {
return (
<div className="metric-table-wrap">
<MetricReportDataTable
columns={trendTabularColumns}
data={trendTabularRows}
stickyLeadingColumns={tabularStickyLeadingCount(
trendTabularColumns
)}
stickyColumnSet="tabular"
/>
</div>
);
}
return (
<div className="text-center text-muted small py-4">
Select a chart.
</div>
);
})()}
</div>
</div>
)}
{isDetailOrDoctorListTab(activeTab) && (
<div className="metric-table-wrap">
{!metricViewId ? (
<div className="p-4 text-center text-muted small">
Missing metric id.
</div>
) : isViewError ? (
<div className="p-4 text-center text-danger small">
Failed to load report data.
</div>
) : isViewFetching ? (
<div className="p-4 text-center text-muted small">
<Spinner animation="border" variant="secondary" size="sm" />{" "}
<span className="ms-1 font-14">
{isDoctorListTabActive
? "Loading doctor list…"
: "Loading details…"}
</span>
</div>
) : isDoctorListTabActive && doctorListValuesShape ? (
<MetricReportDataTable
columns={doctorListTableColumns}
data={doctorListTableRows}
stickyLeadingColumns={tabularStickyLeadingCount(
doctorListTableColumns
)}
stickyColumnSet="tabular"
/>
) : isDoctorListTabActive ? (
<div className="p-4 text-center text-muted small">
No doctor list data for this period.
</div>
) : (
<MetricReportDataTable
columns={detailTableColumns}
data={detailTableRows}
stickyLeadingColumns={tabularStickyLeadingCount(
detailTableColumns
)}
stickyColumnSet="tabular"
/>
)}
</div>
)}
{(activeTab === "My Team" || activeTab === "Leaderboard") && (
<div className="metric-table-wrap">
{!metricViewId ? (
<div className="p-4 text-center text-muted small">
Missing metric id.
</div>
) : isViewError ? (
<div className="p-4 text-center text-danger small">
Failed to load leaderboard.
</div>
) : isViewFetching ? (
<div className="p-4 text-center text-muted small">
<Spinner animation="border" variant="secondary" size="sm" /> <span className="ms-1 font-14">Loading leaderboard…</span>
</div>
) : (
<MetricReportDataTable
columns={leaderboardTableColumns}
data={leaderboardTableRows}
stickyLeadingColumns={2}
getRowClassName={(row) =>
isCurrentUserRow(row) ? "metric-leaderboard-row--self" : ""
}
/>
)}
</div>
)}
</Card.Body>
</Card>
</Container>
</div>
<LeaderboardExportModal
show={showExport}
onHide={() => setShowExport(false)}
showAdditionalFields={
usesLeaderboardExportConfig || !usesLeaderboardTabularConfig
}
alwaysIncludedHint={
usesLeaderboardExportConfig
? "Rank, User, Employee Code, and Units/Points are always included. Additional columns below are selected by default — uncheck any you do not need."
: undefined
}
additionalFieldOptions={leaderboardAdditionalFieldOptions}
exportForm={exportForm}
setExportForm={setExportForm}
onConfirm={confirmLeaderboardExportDownload}
/>
</PageComponent>
);
}
export default MetricReports;