1677 lines
53 KiB
JavaScript
Executable File
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;
|