|
|
|
|
@@ -7,64 +7,154 @@ import {
|
|
|
|
|
Avatar,
|
|
|
|
|
Typography,
|
|
|
|
|
Box,
|
|
|
|
|
Button,
|
|
|
|
|
IconButton
|
|
|
|
|
} from "@mui/material";
|
|
|
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
|
|
|
|
|
|
|
|
export interface LatestItem {
|
|
|
|
|
id: string | number;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
iconBgColor?: string;
|
|
|
|
|
title: string;
|
|
|
|
|
subtitle: string;
|
|
|
|
|
amount: string;
|
|
|
|
|
timeAgo: string;
|
|
|
|
|
import { ReportData, Transaction, ReportPeriod } from "../../features/report";
|
|
|
|
|
import { formatCurrency } from "../ProgressCard/ProgressCard.utils";
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
reportData: ReportData;
|
|
|
|
|
mode: "expense" | "income";
|
|
|
|
|
selectedPeriodId: string | null;
|
|
|
|
|
accentColor: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type DecoratedPeriod = ReportPeriod & {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function mergePeriods(
|
|
|
|
|
reportData: ReportData,
|
|
|
|
|
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
|
|
|
|
|
): DecoratedPeriod[] {
|
|
|
|
|
const map = new Map<string, DecoratedPeriod>();
|
|
|
|
|
|
|
|
|
|
for (const bucket of reportData.buckets) {
|
|
|
|
|
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
|
|
|
|
|
|
|
|
|
|
for (const p of periods) {
|
|
|
|
|
const existing = map.get(p.id);
|
|
|
|
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
map.set(p.id, {
|
|
|
|
|
...p,
|
|
|
|
|
expenses: {
|
|
|
|
|
...p.expenses,
|
|
|
|
|
transactions: [...(p.expenses.transactions || [])],
|
|
|
|
|
},
|
|
|
|
|
incomes: {
|
|
|
|
|
...p.incomes,
|
|
|
|
|
transactions: [...(p.incomes.transactions || [])],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
existing.expenses.transactions?.push(...(p.expenses.transactions || []));
|
|
|
|
|
existing.incomes.transactions?.push(...(p.incomes.transactions || []));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(map.values());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LatestItemsListProps {
|
|
|
|
|
title?: string;
|
|
|
|
|
items: LatestItem[];
|
|
|
|
|
onViewAll?: () => void;
|
|
|
|
|
accentColor: any;
|
|
|
|
|
function extractTransactions(
|
|
|
|
|
reportData: ReportData,
|
|
|
|
|
selectedPeriodId: string | null,
|
|
|
|
|
mode: "expense" | "income",
|
|
|
|
|
): Transaction[] {
|
|
|
|
|
let periods: DecoratedPeriod[] = [];
|
|
|
|
|
|
|
|
|
|
if (selectedPeriodId) {
|
|
|
|
|
const prefix = selectedPeriodId.split(":")[0];
|
|
|
|
|
|
|
|
|
|
const map: any = {
|
|
|
|
|
W: "weekly",
|
|
|
|
|
M: "monthly",
|
|
|
|
|
Y: "yearly",
|
|
|
|
|
FY: "fyly",
|
|
|
|
|
FULL: "full"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const key = map[prefix];
|
|
|
|
|
|
|
|
|
|
periods = mergePeriods(reportData, key);
|
|
|
|
|
const selected = periods.find(p => p.id === selectedPeriodId);
|
|
|
|
|
|
|
|
|
|
if (!selected) return [];
|
|
|
|
|
|
|
|
|
|
return mode === "expense"
|
|
|
|
|
? (selected.expenses.transactions || [])
|
|
|
|
|
: (selected.incomes.transactions || []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
periods = mergePeriods(reportData, "full");
|
|
|
|
|
|
|
|
|
|
if (!periods.length) return [];
|
|
|
|
|
|
|
|
|
|
const full = periods[0];
|
|
|
|
|
|
|
|
|
|
return mode === "expense"
|
|
|
|
|
? (full.expenses.transactions || [])
|
|
|
|
|
: (full.incomes.transactions || []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function LatestItems({
|
|
|
|
|
title = "Recent Transactions",
|
|
|
|
|
items,
|
|
|
|
|
onViewAll,
|
|
|
|
|
accentColor,
|
|
|
|
|
}: LatestItemsListProps) {
|
|
|
|
|
reportData,
|
|
|
|
|
mode,
|
|
|
|
|
selectedPeriodId,
|
|
|
|
|
accentColor
|
|
|
|
|
}: Props) {
|
|
|
|
|
const [visibleCount, setVisibleCount] = React.useState(5);
|
|
|
|
|
|
|
|
|
|
const items = React.useMemo(() => {
|
|
|
|
|
const txns = extractTransactions(reportData, selectedPeriodId, mode);
|
|
|
|
|
|
|
|
|
|
return txns
|
|
|
|
|
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
|
|
|
|
|
.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
new Date(b.occurred_at).getTime() -
|
|
|
|
|
new Date(a.occurred_at).getTime()
|
|
|
|
|
)
|
|
|
|
|
.map((t, index) => ({
|
|
|
|
|
id: index + 1,
|
|
|
|
|
title: t.payee.name,
|
|
|
|
|
subtitle: t.tags.map((tag) => tag.name).join(", "),
|
|
|
|
|
amount: formatCurrency(t.amount),
|
|
|
|
|
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
|
|
|
|
|
}));
|
|
|
|
|
}, [reportData, selectedPeriodId, mode]);
|
|
|
|
|
|
|
|
|
|
const isPeriodSelected = Boolean(selectedPeriodId);
|
|
|
|
|
|
|
|
|
|
const visibleItems = React.useMemo(() => {
|
|
|
|
|
if (!isPeriodSelected) return items.slice(0, 5);
|
|
|
|
|
return items.slice(0, visibleCount);
|
|
|
|
|
}, [items, isPeriodSelected, visibleCount]);
|
|
|
|
|
|
|
|
|
|
const canExpand = isPeriodSelected && visibleCount < items.length;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
|
|
|
|
|
<Box sx={{ mb: 2, px: 2 }}>
|
|
|
|
|
<Typography variant="h6" fontWeight="bold">
|
|
|
|
|
{title}
|
|
|
|
|
Recent Transactions
|
|
|
|
|
</Typography>
|
|
|
|
|
{onViewAll && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="text"
|
|
|
|
|
color="inherit"
|
|
|
|
|
size="small"
|
|
|
|
|
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
|
|
|
|
|
onClick={onViewAll}
|
|
|
|
|
>
|
|
|
|
|
view all
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
{/* List */}
|
|
|
|
|
<List disablePadding>
|
|
|
|
|
{items.map((item, index) => (
|
|
|
|
|
{visibleItems.map((item, index) => (
|
|
|
|
|
<ListItem
|
|
|
|
|
key={item.id}
|
|
|
|
|
sx={{
|
|
|
|
|
px: { xs: 1, sm: 2 },
|
|
|
|
|
py: 2,
|
|
|
|
|
mb: index !== items.length - 1 ? 1 : 0,
|
|
|
|
|
mb: index !== visibleItems.length - 1 ? 1 : 0,
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
"&:hover": { bgcolor: "action.hover" },
|
|
|
|
|
transition: "background-color 0.2s ease",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ListItemAvatar>
|
|
|
|
|
@@ -72,20 +162,17 @@ export default function LatestItems({
|
|
|
|
|
variant="rounded"
|
|
|
|
|
sx={{
|
|
|
|
|
bgcolor: `${accentColor}22`,
|
|
|
|
|
color: "inherit",
|
|
|
|
|
width: 48,
|
|
|
|
|
height: 48,
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
mr: 2,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{item.icon}
|
|
|
|
|
</Avatar>
|
|
|
|
|
/>
|
|
|
|
|
</ListItemAvatar>
|
|
|
|
|
|
|
|
|
|
<ListItemText
|
|
|
|
|
primary={
|
|
|
|
|
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
|
|
|
|
|
<Typography variant="subtitle1" fontWeight={600}>
|
|
|
|
|
{item.title}
|
|
|
|
|
</Typography>
|
|
|
|
|
}
|
|
|
|
|
@@ -97,15 +184,26 @@ export default function LatestItems({
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Box sx={{ textAlign: "right" }}>
|
|
|
|
|
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
|
|
|
|
|
<Typography variant="subtitle1" fontWeight={700}>
|
|
|
|
|
{item.amount}
|
|
|
|
|
</Typography>
|
|
|
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
|
|
|
<Typography variant="caption" color="text.secondary">
|
|
|
|
|
{item.timeAgo}
|
|
|
|
|
</Typography>
|
|
|
|
|
</Box>
|
|
|
|
|
</ListItem>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{canExpand && (
|
|
|
|
|
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="small"
|
|
|
|
|
onClick={() => setVisibleCount((prev) => prev + 5)}
|
|
|
|
|
>
|
|
|
|
|
<ExpandMoreIcon />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Box>
|
|
|
|
|
)}
|
|
|
|
|
</List>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
|