Files
khata-ui/src/components/HistoryChart.tsx

292 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from "react";
import { Box, Typography, ToggleButtonGroup, ToggleButton, Paper } from "@mui/material";
export interface ChartDataPoint {
id: string;
amount: number;
compareAmount?: number;
compareLabel?: string;
highlighted?: boolean;
}
export interface ChartSeries {
rolling: ChartDataPoint[];
calendar: ChartDataPoint[];
}
export interface ChartData {
daily: ChartDataPoint[];
weekly: ChartSeries;
monthly: ChartSeries;
}
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
data: ChartData;
period: "rolling" | "calendar";
onPeriodChange: (mode: "rolling" | "calendar") => void;
comparison: boolean;
setComparison: (mode: boolean) => void;
}
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,
}: 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;
return (
<Paper
sx={{
p: { xs: 2, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider"
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{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>
<ToggleButtonGroup
value={period}
exclusive
onChange={(_, v) => v && onPeriodChange(v)}
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar" disabled={activeDataKey === "daily"}>
Calendar
</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={comparison ? "on" : "off"}
exclusive
onChange={(_, v) => setComparison(v === "on")}
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="off">Single</ToggleButton>
<ToggleButton value="on">Compare</ToggleButton>
</ToggleButtonGroup>
{currentData.length > 0 ? (
<Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}>
{currentData.map((point) => {
const currentHeight = (point.amount / maxAmount) * 100;
const compareHeight = ((point.compareAmount || 0) / maxAmount) * 100;
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"
}}
>
{comparison && (
<Box
sx={{
width: 6,
height: `${compareHeight}%`,
bgcolor: "grey.400",
borderRadius: 2
}}
/>
)}
<Box
sx={{
width: 10,
height: `${currentHeight}%`,
bgcolor: point.highlighted ? "error.main" : "primary.main",
borderRadius: 2,
position: "relative"
}}
>
<Typography
variant="caption"
sx={{
position: "absolute",
top: -18,
left: "50%",
transform: "translateX(-50%)",
fontSize: "0.65rem",
whiteSpace: "nowrap"
}}
>
{formatDisplay(point, activeTab.toLowerCase(), comparison)}
</Typography>
</Box>
</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>
) : (
<Box sx={{ height: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Typography color="text.secondary">No Data Available</Typography>
</Box>
)}
</Paper>
);
}