392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
import * as React from "react";
|
||
import {
|
||
Box,
|
||
Typography,
|
||
ToggleButtonGroup,
|
||
ToggleButton,
|
||
Paper
|
||
} from "@mui/material";
|
||
import {
|
||
ChartDataPoint,
|
||
HistoryChartProps,
|
||
ChartData,
|
||
} from "../types/historyChart";
|
||
import IconButton from "@mui/material/IconButton";
|
||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||
|
||
const formatDisplay = (
|
||
point: ChartDataPoint,
|
||
tab: string,
|
||
comparison: boolean
|
||
) => {
|
||
const base = point.amount;
|
||
const cmp = point.compareAmount ?? 0;
|
||
|
||
const formatShort = (val: number) => {
|
||
if (tab === "monthly") {
|
||
if (val >= 100000) return `${(val / 100000).toFixed(2)}L`;
|
||
}
|
||
if (tab === "weekly") {
|
||
if (val >= 1000) return `${(val / 1000).toFixed(1)}K`;
|
||
}
|
||
return val.toLocaleString("en-IN");
|
||
};
|
||
|
||
// Only hide diff when comparison OFF or compare is undefined
|
||
if (!comparison) {
|
||
return `₹ ${formatShort(base)}`;
|
||
}
|
||
|
||
const diff = base - cmp;
|
||
const sign = diff >= 0 ? "+" : "-";
|
||
const absDiff = Math.abs(diff);
|
||
|
||
return `₹ ${formatShort(base)} (${sign}${formatShort(absDiff)})`;
|
||
};
|
||
|
||
const formatLabel = (label: string, type: string) => {
|
||
if (type === "monthly") return label;
|
||
|
||
if (type === "weekly") {
|
||
const parts = label.split(" - ");
|
||
if (parts.length === 2) {
|
||
const [start, end] = parts;
|
||
const startDay = start.split(" ")[0];
|
||
const endParts = end.split(" ");
|
||
const endDay = endParts[0];
|
||
const month = endParts[1];
|
||
return `${startDay}–${endDay} ${month}`;
|
||
}
|
||
}
|
||
|
||
return label;
|
||
};
|
||
|
||
export default function HistoryChart({
|
||
header,
|
||
summary,
|
||
tabs,
|
||
data,
|
||
period,
|
||
onPeriodChange,
|
||
comparison,
|
||
setComparison,
|
||
colorScheme,
|
||
}: HistoryChartProps) {
|
||
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
|
||
|
||
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
|
||
if (newTab !== null) setActiveTab(newTab);
|
||
};
|
||
|
||
const activeDataKey = activeTab.toLowerCase() as keyof ChartData;
|
||
|
||
let rawData: ChartDataPoint[] = [];
|
||
|
||
if (activeDataKey === "daily") {
|
||
rawData = data.daily || [];
|
||
} else {
|
||
const section = data[activeDataKey];
|
||
rawData = section?.[period] || [];
|
||
}
|
||
|
||
const currentData = rawData;
|
||
|
||
const maxAmount =
|
||
currentData.length > 0
|
||
? Math.max(
|
||
...currentData.flatMap((d) =>
|
||
comparison ? [d.amount, d.compareAmount || 0] : [d.amount]
|
||
),
|
||
1
|
||
)
|
||
: 1;
|
||
|
||
const [startIndex, setStartIndex] = React.useState(0);
|
||
const visibleCountDataTabMapping = {
|
||
daily: 7,
|
||
weekly: 6,
|
||
monthly: 4,
|
||
}
|
||
const visibleCount = visibleCountDataTabMapping[activeDataKey];
|
||
const total = currentData.length;
|
||
|
||
// clamp startIndex so we always show full 5 (when possible)
|
||
const clampedStartIndex = Math.min(
|
||
startIndex,
|
||
Math.max(total - visibleCount, 0)
|
||
);
|
||
|
||
const visibleData = currentData.slice(
|
||
clampedStartIndex,
|
||
clampedStartIndex + visibleCount
|
||
);
|
||
|
||
const canGoLeft = startIndex > 0;
|
||
const canGoRight = startIndex + visibleCount < currentData.length;
|
||
|
||
const handlePrev = () => {
|
||
if (canGoLeft) setStartIndex((prev) => prev - visibleCount);
|
||
};
|
||
|
||
const handleNext = () => {
|
||
if (canGoRight) setStartIndex((prev) => prev + visibleCount);
|
||
};
|
||
|
||
return (
|
||
<Paper
|
||
sx={{
|
||
p: { xs: 2, sm: 4 },
|
||
borderRadius: 4,
|
||
width: "100%",
|
||
boxShadow: "none",
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
bgcolor: colorScheme.light,
|
||
}}
|
||
>
|
||
<Typography variant="h6" fontWeight={700} gutterBottom color={colorScheme.text}>
|
||
{header}
|
||
</Typography>
|
||
|
||
{summary && (
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||
{summary}
|
||
</Typography>
|
||
)}
|
||
|
||
<ToggleButtonGroup
|
||
value={activeTab}
|
||
exclusive
|
||
onChange={handleTabChange}
|
||
fullWidth
|
||
sx={{ mb: 4 }}
|
||
>
|
||
{tabs.map((tab) => (
|
||
<ToggleButton key={tab} value={tab}>
|
||
{tab}
|
||
</ToggleButton>
|
||
))}
|
||
</ToggleButtonGroup>
|
||
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
mb: 3
|
||
}}
|
||
>
|
||
{/* Rolling / Calendar */}
|
||
<ToggleButtonGroup
|
||
value={period}
|
||
exclusive
|
||
onChange={(_, v) => v && onPeriodChange(v)}
|
||
size="small"
|
||
>
|
||
<ToggleButton value="rolling">Rolling</ToggleButton>
|
||
<ToggleButton
|
||
value="calendar"
|
||
disabled={activeDataKey === "daily"}
|
||
>
|
||
Calendar
|
||
</ToggleButton>
|
||
</ToggleButtonGroup>
|
||
|
||
{/* Compare toggle */}
|
||
<ToggleButton
|
||
value="compare"
|
||
selected={comparison}
|
||
onChange={() => setComparison(!comparison)}
|
||
size="small"
|
||
sx={{
|
||
textTransform: "none",
|
||
borderRadius: 2,
|
||
px: 2,
|
||
|
||
// OFF
|
||
color: "text.secondary",
|
||
border: "1px solid",
|
||
borderColor: "divider",
|
||
|
||
// ON
|
||
"&.Mui-selected": {
|
||
color: "white",
|
||
bgcolor: "success.main",
|
||
borderColor: "success.main"
|
||
},
|
||
"&.Mui-selected:hover": {
|
||
bgcolor: "success.dark"
|
||
}
|
||
}}
|
||
>
|
||
Compare
|
||
</ToggleButton>
|
||
</Box>
|
||
|
||
{currentData.length > 0 ? (
|
||
<Box sx={{ position: "relative", mt: 4 }}>
|
||
|
||
{/* LEFT ARROW */}
|
||
{canGoLeft && (
|
||
<IconButton
|
||
onClick={handlePrev}
|
||
size="small"
|
||
sx={{
|
||
position: "absolute",
|
||
left: 0,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
zIndex: 2,
|
||
bgcolor: "background.paper",
|
||
boxShadow: 1
|
||
}}
|
||
>
|
||
<ChevronLeftIcon fontSize="small" />
|
||
</IconButton>
|
||
)}
|
||
|
||
{/* CHART */}
|
||
<Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}>
|
||
{visibleData.map((point) => {
|
||
const currentHeight = (point.amount / maxAmount) * 100;
|
||
const compareHeight = comparison
|
||
? ((point.compareAmount || 0) / maxAmount) * 100
|
||
: 0;
|
||
const labelHeight = Math.max(currentHeight, compareHeight);
|
||
|
||
return (
|
||
<Box
|
||
key={point.id}
|
||
sx={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "flex-end",
|
||
height: "100%"
|
||
}}
|
||
>
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
alignItems: "flex-end",
|
||
gap: comparison ? 0.5 : 0,
|
||
height: "100%",
|
||
position: "relative"
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
position: "absolute",
|
||
bottom: `${labelHeight}%`,
|
||
left: "50%",
|
||
transform: "translate(-50%, -6px)",
|
||
fontSize: "0.65rem",
|
||
whiteSpace: "nowrap",
|
||
pointerEvents: "none"
|
||
}}
|
||
>
|
||
{formatDisplay(point, activeTab.toLowerCase(), comparison)}
|
||
</Typography>
|
||
|
||
{/* Compare */}
|
||
{comparison && (
|
||
<Box
|
||
sx={{
|
||
width: 6,
|
||
height: `${compareHeight}%`,
|
||
bgcolor: `${colorScheme.primary}55`,
|
||
borderRadius: 2
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Spacer */}
|
||
<Box sx={{ width: 4 }} />
|
||
|
||
{/* Current */}
|
||
<Box
|
||
sx={{
|
||
width: 10,
|
||
height: `${currentHeight}%`,
|
||
bgcolor: point.highlighted
|
||
? colorScheme.primary
|
||
: `${colorScheme.primary}99`,
|
||
borderRadius: 2
|
||
}}
|
||
/>
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
mt: 1,
|
||
textAlign: "center",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
lineHeight: 1.1
|
||
}}
|
||
>
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
fontSize: "0.7rem",
|
||
opacity: 0.7,
|
||
color: "text.primary",
|
||
}}
|
||
>
|
||
{formatLabel(point.id, activeDataKey)}
|
||
</Typography>
|
||
|
||
<Typography
|
||
variant="caption"
|
||
sx={{
|
||
fontSize: "0.65rem",
|
||
color: "grey.400",
|
||
visibility:
|
||
comparison && point.compareLabel && activeDataKey !== "daily"
|
||
? "visible"
|
||
: "hidden"
|
||
}}
|
||
>
|
||
{point.compareLabel
|
||
? formatLabel(point.compareLabel, activeDataKey)
|
||
: "placeholder"}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
|
||
{/* RIGHT ARROW */}
|
||
{canGoRight && (
|
||
<IconButton
|
||
onClick={handleNext}
|
||
size="small"
|
||
sx={{
|
||
position: "absolute",
|
||
right: 0,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
zIndex: 2,
|
||
bgcolor: "background.paper",
|
||
boxShadow: 1
|
||
}}
|
||
>
|
||
<ChevronRightIcon fontSize="small" />
|
||
</IconButton>
|
||
)}
|
||
</Box>
|
||
) : (
|
||
<Box sx={{ height: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
<Typography color="text.secondary">No Data Available</Typography>
|
||
</Box>
|
||
)}
|
||
</Paper>
|
||
);
|
||
}
|