1 Commits

Author SHA1 Message Date
13f091a82c ui fixes for snapshot report 2026-05-17 19:14:45 +05:30
12 changed files with 110 additions and 123 deletions

View File

@@ -11,13 +11,21 @@ import {
} from "@mui/material"; } from "@mui/material";
import ConfigurableDashboard from "./components/Dashboard"; import ConfigurableDashboard from "./components/Dashboard";
import { DashboardState } from "./components/Dashboard/Dashboard.models";
import { configuration } from "./dashboard-config"; import { configuration } from "./dashboard-config";
import { import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } from "./features/report";
/** Map the internal UI mode to the API flow param */
function modeToFlow(mode: "expense" | "income"): "outflows" | "inflows" {
return mode === "expense" ? "outflows" : "inflows";
}
export default function Dashboard() { export default function Dashboard() {
const [mode, setMode] = React.useState<"expense" | "income">("expense");
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]); const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]); const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
@@ -28,10 +36,8 @@ export default function Dashboard() {
const [loadedTags, setLoadedTags] = React.useState<string[]>([]); const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const report = useReport({ const report = useReport({
periods: ["weekly", "monthly", "full"], periods: ["weekly", "monthly", "all"],
rolling: true, flow: modeToFlow(mode),
include_transactions: true,
group_by: ["tags"],
payee: appliedPayees.length > 0 ? appliedPayees : undefined, payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined, tags: appliedTags.length > 0 ? appliedTags : undefined,
}); });
@@ -43,10 +49,7 @@ export default function Dashboard() {
report.data.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.expenses?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
p.incomes?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name); if (t.payee?.name) pSet.add(t.payee.name);
}); });
}); });
@@ -60,10 +63,7 @@ export default function Dashboard() {
report.data.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.expenses?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
p.incomes?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag)); t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
}); });
}); });
@@ -77,6 +77,10 @@ export default function Dashboard() {
const isLoading = report.isLoading; const isLoading = report.isLoading;
const error = report.error; const error = report.error;
/** Callback for the ConfigurableDashboard's mode toggle */
const handleModeChange = React.useCallback((newState: DashboardState) => {
setMode(newState.mode);
}, []);
if (isLoading && !report.data) { if (isLoading && !report.data) {
return ( return (
@@ -152,7 +156,7 @@ export default function Dashboard() {
setAppliedTags(tagsInput); setAppliedTags(tagsInput);
}} }}
disabled={isLoading} disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }} // Changed from 56 to 40 to match minHeight of inputs sx={{ height: 40, borderRadius: 2 }}
> >
Apply Apply
</Button> </Button>
@@ -161,6 +165,7 @@ export default function Dashboard() {
<ConfigurableDashboard <ConfigurableDashboard
config={configuration} config={configuration}
data={data} data={data}
onModeChange={handleModeChange}
/> />
</Box> </Box>
); );

View File

@@ -50,4 +50,5 @@ export interface DashboardConfig {
export interface DashboardProps { export interface DashboardProps {
config: DashboardConfig; config: DashboardConfig;
data: ReportData; data: ReportData;
onModeChange?: (state: DashboardState) => void;
} }

View File

@@ -12,10 +12,14 @@ export default function Dashboard(props: DashboardProps) {
}); });
const toggleMode = () => { const toggleMode = () => {
setState(prev => ({ setState(prev => {
...prev, const next = {
mode: prev.mode === "expense" ? "income" : "expense", ...prev,
})); mode: prev.mode === "expense" ? "income" as const : "expense" as const,
};
props.onModeChange?.(next);
return next;
});
}; };
const togglePeriodType = () => { const togglePeriodType = () => {

View File

@@ -9,15 +9,14 @@ import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ───────────────────────────────────────── // ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = { const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly", Weekly: "weekly",
Monthly: "monthly", Monthly: "monthly",
Yearly: "yearly", "All Time": "all",
"Financial Year": "fyly",
"All Time": "full",
}; };
export function tabToKey(tab: string): PeriodKey { export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "full"; return TAB_TO_KEY[tab] ?? "all";
} }
// ─── Comparison ────────────────────────────────────────────── // ─── Comparison ──────────────────────────────────────────────
@@ -27,10 +26,9 @@ function attachComparison(
key: PeriodKey key: PeriodKey
): ChartDataPoint[] { ): ChartDataPoint[] {
const getCompareIndex = (i: number) => { const getCompareIndex = (i: number) => {
if (key === "daily") return i - 7;
if (key === "weekly") return i - 4; if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12; if (key === "monthly") return i - 12;
if (key === "yearly") return i - 1;
if (key === "fyly") return i - 1;
return -1; return -1;
}; };
@@ -64,7 +62,7 @@ export function buildChartData(
let points: ChartDataPoint[] = merged.map((p) => ({ let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id, id: p.id,
label: p.label, label: p.label,
amount: getAmount(p, mode), amount: getAmount(p),
})); }));
if (comparison) { if (comparison) {

View File

@@ -35,11 +35,10 @@ export default function HistoryChart(props: HistoryChartProps) {
: 1; : 1;
const visibleCountMap = { const visibleCountMap = {
daily: 7,
weekly: 6, weekly: 6,
monthly: 4, monthly: 4,
yearly: 4, all: 4,
fyly: 4,
full: 4,
}; };
const visibleCount = visibleCountMap[activeDataKey] ?? 4; const visibleCount = visibleCountMap[activeDataKey] ?? 4;

View File

@@ -13,7 +13,6 @@ function extractTransactions(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null, selectedGroupKey: GroupKey | null,
mode: "expense" | "income"
): Transaction[] { ): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey); const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
if (selectedPeriodId) { if (selectedPeriodId) {
@@ -23,20 +22,16 @@ function extractTransactions(
if (!selected) return []; if (!selected) return [];
return mode === "expense" return selected.metric.transactions || [];
? (selected.expenses.transactions || [])
: (selected.incomes.transactions || []);
} }
const periods = mergeBucketPeriods(buckets, "full"); const periods = mergeBucketPeriods(buckets, "all");
if (!periods.length) return []; if (!periods.length) return [];
const full = periods[0]; const full = periods[0];
return mode === "expense" return full.metric.transactions || [];
? (full.expenses.transactions || [])
: (full.incomes.transactions || []);
} }
// ─── Main adapter ──────────────────────────────────────────── // ─── Main adapter ────────────────────────────────────────────
@@ -47,10 +42,9 @@ export function buildLatestItems(
selectedGroupKey: GroupKey | null, selectedGroupKey: GroupKey | null,
mode: "expense" | "income" mode: "expense" | "income"
): LatestItem[] { ): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode); const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.occurred_at).getTime() - new Date(b.occurred_at).getTime() -

View File

@@ -43,17 +43,17 @@ export function extractTopTags(
const tags = bucket.group_key.tags; const tags = bucket.group_key.tags;
if (!tags || tags.length === 0) continue; if (!tags || tags.length === 0) continue;
// Prefer FULL if available // Prefer ALL if available
const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[]; const allPeriods = (bucket.periods.all || []) as DecoratedPeriod[];
const periodsToUse = selectedPeriodId const periodsToUse = selectedPeriodId
? (Object.values(bucket.periods).flat() as DecoratedPeriod[]) ? (Object.values(bucket.periods).flat() as DecoratedPeriod[])
: fullPeriods; : allPeriods;
const period = findPeriod(periodsToUse, selectedPeriodId); const period = findPeriod(periodsToUse, selectedPeriodId);
if (!period) continue; if (!period) continue;
const amount = getAmount(period, mode); const amount = getAmount(period);
for (const tag of tags) { for (const tag of tags) {
tagMap.set(tag, (tagMap.get(tag) || 0) + amount); tagMap.set(tag, (tagMap.get(tag) || 0) + amount);

View File

@@ -2,11 +2,12 @@ import {
ReportPeriod, ReportPeriod,
ReportBucket, ReportBucket,
GroupKey, GroupKey,
PeriodType,
} from "../features/report"; } from "../features/report";
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full"; export type PeriodKey = PeriodType;
export type DecoratedPeriod = ReportPeriod & { export type DecoratedPeriod = ReportPeriod & {
id: string; id: string;
@@ -16,11 +17,10 @@ export type DecoratedPeriod = ReportPeriod & {
// ─── Period helpers ─────────────────────────────────────────── // ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = { const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly", W: "weekly",
M: "monthly", M: "monthly",
Y: "yearly", ALL: "all",
FY: "fyly",
FULL: "full",
}; };
/** /**
@@ -29,19 +29,16 @@ const PREFIX_TO_KEY: Record<string, PeriodKey> = {
*/ */
export function periodIdToKey(periodId: string): PeriodKey { export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0]; const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "full"; return PREFIX_TO_KEY[prefix] ?? "all";
} }
// ─── Metric helpers ─────────────────────────────────────────── // ─── Metric helpers ───────────────────────────────────────────
export function getAmount( export function getAmount(period: ReportPeriod): number {
period: ReportPeriod, return period.metric.sum;
mode: "expense" | "income"
): number {
return mode === "expense" ? period.expenses.sum : period.incomes.sum;
} }
function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) { function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
const sum = a.sum + b.sum; const sum = a.sum + b.sum;
const count = a.count + b.count; const count = a.count + b.count;
@@ -78,14 +75,12 @@ export function mergeBucketPeriods(
if (!existing) { if (!existing) {
map.set(p.id, { map.set(p.id, {
...p, ...p,
expenses: { ...p.expenses }, metric: { ...p.metric },
incomes: { ...p.incomes },
}); });
} else { } else {
map.set(p.id, { map.set(p.id, {
...existing, ...existing,
expenses: mergeMetric(existing.expenses, p.expenses), metric: mergeMetric(existing.metric, p.metric),
incomes: mergeMetric(existing.incomes, p.incomes),
}); });
} }
} }
@@ -126,7 +121,7 @@ export function matchesGroupKey(
selected: GroupKey selected: GroupKey
): boolean { ): boolean {
for (const [dim, values] of Object.entries(selected)) { for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim as keyof GroupKey]; const bucketValues = bucket.group_key[dim];
if (!bucketValues) return false; if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v))) if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false; return false;

View File

@@ -6,7 +6,9 @@ export type {
ReportData, ReportData,
ReportBucket, ReportBucket,
ReportPeriod, ReportPeriod,
ReportQuery,
GroupKey, GroupKey,
PeriodType,
} from './report.models' } from './report.models'
export { export {
prepareReport prepareReport

View File

@@ -1,29 +1,40 @@
export interface Payor { export interface Payor {
id?: string;
name: string; name: string;
username: string;
email: string;
} }
export interface Payee { export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string; name: string;
} }
export interface Account { export interface Account {
id: string;
name: string; name: string;
number: string; number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
} }
export interface Tag { export interface Tag {
id: string;
name: string; name: string;
icon: string; icon: string;
description: string; parent_id?: string | null;
} }
export interface Transaction { export interface Transaction {
id: string;
payor: Payor; payor: Payor;
payee: Payee; payee: Payee;
amount: number; amount: number;
account: Account; account: Account;
tags: Tag[]; tags: Tag[];
occurred_at: Date; occurred_at: string;
created_at: string;
} }
// ----------------------------- // -----------------------------
@@ -41,12 +52,12 @@ export interface ReportMetric {
// Period // Period
// ----------------------------- // -----------------------------
export interface ReportPeriod { export type PeriodType = "daily" | "weekly" | "monthly" | "all";
start: Date;
end: Date;
expenses: ReportMetric; export interface ReportPeriod {
incomes: ReportMetric; start: string;
end: string;
metric: ReportMetric;
} }
// ----------------------------- // -----------------------------
@@ -54,46 +65,48 @@ export interface ReportPeriod {
// ----------------------------- // -----------------------------
export type GroupKey = { export type GroupKey = {
payee?: string[]; [dimension: string]: string[];
tags?: string[];
flow?: string[];
}; };
export interface ReportBucket { export interface ReportBucket {
group_key: GroupKey; group_key: GroupKey;
periods: { periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[]; weekly?: ReportPeriod[];
monthly?: ReportPeriod[]; monthly?: ReportPeriod[];
yearly?: ReportPeriod[]; all?: ReportPeriod[];
fyly?: ReportPeriod[];
full?: ReportPeriod[];
}; };
} }
// -----------------------------
// Report Query
// -----------------------------
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
// ----------------------------- // -----------------------------
// Final Report // Final Report
// ----------------------------- // -----------------------------
export interface ReportData { export interface ReportData {
periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; snapshot_id?: string | null;
rolling: boolean; flow?: "inflows" | "outflows" | null;
report_date?: string;
group_by: ("payee" | "tags")[]; periods: PeriodType[];
ignore_self: boolean;
include_transactions: boolean;
start_date?: string | null;
end_date?: string | null;
flow?: "expense" | "income" | null;
payee?: string[] | null;
account?: string[] | null;
tags?: string[] | null; tags?: string[] | null;
min_amount?: number | null; payee?: string[] | null;
max_amount?: number | null;
buckets: ReportBucket[]; buckets: ReportBucket[];
query: ReportQuery;
} }

View File

@@ -1,6 +1,7 @@
import { import {
ReportData, ReportData,
ReportPeriod ReportPeriod,
PeriodType,
} from "./report.models"; } from "./report.models";
/* ---------- ID BUILDING ---------- */ /* ---------- ID BUILDING ---------- */
@@ -13,7 +14,7 @@ function formatDate(d: Date): string {
} }
function buildPeriodId( function buildPeriodId(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
start: Date, start: Date,
end: Date end: Date
): string { ): string {
@@ -21,16 +22,14 @@ function buildPeriodId(
const e = formatDate(end); const e = formatDate(end);
switch (type) { switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly": case "weekly":
return `W:${s}_${e}`; return `W:${s}_${e}`;
case "monthly": case "monthly":
return `M:${s}_${e}`; return `M:${s}_${e}`;
case "yearly": case "all":
return `Y:${s}_${e}`; return `ALL:${s}_${e}`;
case "fyly":
return `FY:${s}_${e}`;
case "full":
return `FULL:${s}_${e}`;
default: default:
return `${s}_${e}`; return `${s}_${e}`;
} }
@@ -60,19 +59,15 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "UTC", timeZone: "UTC",
}); });
function sameMonth(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth()
);
}
function buildLabel( function buildLabel(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
start: Date, start: Date,
end: Date end: Date
): string { ): string {
switch (type) { switch (type) {
case "daily":
return dayFmt.format(start);
case "weekly": { case "weekly": {
const sDay = start.getUTCDate(); const sDay = start.getUTCDate();
const m = monthFmt.format(start); const m = monthFmt.format(start);
@@ -82,15 +77,6 @@ function buildLabel(
case "monthly": case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`; return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
case "yearly":
return yearFmt.format(start);
case "fyly": {
const startY = start.getUTCFullYear();
const endY = end.getUTCFullYear();
return `FY ${startY}${String(endY).slice(-2)}`;
}
default: default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
} }
@@ -99,7 +85,7 @@ function buildLabel(
/* ---------- MAIN ---------- */ /* ---------- MAIN ---------- */
function decoratePeriods( function decoratePeriods(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
periods: ReportPeriod[] periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] { ): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({ return periods.map((p) => ({

View File

@@ -1,20 +1,11 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; snapshot_id?: string;
rolling?: boolean; periods?: ("daily" | "weekly" | "monthly" | "all")[];
report_date?: string; flow?: "inflows" | "outflows";
group_by?: ("payee" | "tags")[];
ignore_self?: boolean;
include_transactions?: boolean;
start_date?: string;
end_date?: string;
flow?: "expense" | "income";
payee?: string[]; payee?: string[];
account?: string[];
tags?: string[]; tags?: string[];
min_amount?: number;
max_amount?: number;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
@@ -23,6 +14,5 @@ export function useReport(params: ReportParams) {
return useList({ return useList({
...params, ...params,
periods: params.periods, periods: params.periods,
group_by: params.group_by,
}); });
} }