Compare commits
1 Commits
main
...
13f091a82c
| Author | SHA1 | Date | |
|---|---|---|---|
| 13f091a82c |
@@ -11,13 +11,21 @@ import {
|
||||
} from "@mui/material";
|
||||
|
||||
import ConfigurableDashboard from "./components/Dashboard";
|
||||
import { DashboardState } from "./components/Dashboard/Dashboard.models";
|
||||
import { configuration } from "./dashboard-config";
|
||||
import {
|
||||
useReport,
|
||||
prepareReport,
|
||||
} 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() {
|
||||
const [mode, setMode] = React.useState<"expense" | "income">("expense");
|
||||
|
||||
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
|
||||
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
|
||||
|
||||
@@ -28,10 +36,8 @@ export default function Dashboard() {
|
||||
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
|
||||
|
||||
const report = useReport({
|
||||
periods: ["weekly", "monthly", "full"],
|
||||
rolling: true,
|
||||
include_transactions: true,
|
||||
group_by: ["tags"],
|
||||
periods: ["weekly", "monthly", "all"],
|
||||
flow: modeToFlow(mode),
|
||||
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
|
||||
tags: appliedTags.length > 0 ? appliedTags : undefined,
|
||||
});
|
||||
@@ -43,10 +49,7 @@ export default function Dashboard() {
|
||||
report.data.data.buckets.forEach((b: any) => {
|
||||
Object.values(b.periods).forEach((periodArray: any) => {
|
||||
periodArray?.forEach((p: any) => {
|
||||
p.expenses?.transactions?.forEach((t: any) => {
|
||||
if (t.payee?.name) pSet.add(t.payee.name);
|
||||
});
|
||||
p.incomes?.transactions?.forEach((t: any) => {
|
||||
p.metric?.transactions?.forEach((t: any) => {
|
||||
if (t.payee?.name) pSet.add(t.payee.name);
|
||||
});
|
||||
});
|
||||
@@ -60,10 +63,7 @@ export default function Dashboard() {
|
||||
report.data.data.buckets.forEach((b: any) => {
|
||||
Object.values(b.periods).forEach((periodArray: any) => {
|
||||
periodArray?.forEach((p: any) => {
|
||||
p.expenses?.transactions?.forEach((t: any) => {
|
||||
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
|
||||
});
|
||||
p.incomes?.transactions?.forEach((t: any) => {
|
||||
p.metric?.transactions?.forEach((t: any) => {
|
||||
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,10 @@ export default function Dashboard() {
|
||||
const isLoading = report.isLoading;
|
||||
const error = report.error;
|
||||
|
||||
/** Callback for the ConfigurableDashboard's mode toggle */
|
||||
const handleModeChange = React.useCallback((newState: DashboardState) => {
|
||||
setMode(newState.mode);
|
||||
}, []);
|
||||
|
||||
if (isLoading && !report.data) {
|
||||
return (
|
||||
@@ -152,7 +156,7 @@ export default function Dashboard() {
|
||||
setAppliedTags(tagsInput);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
sx={{ height: 40, borderRadius: 2 }} // Changed from 56 to 40 to match minHeight of inputs
|
||||
sx={{ height: 40, borderRadius: 2 }}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
@@ -161,6 +165,7 @@ export default function Dashboard() {
|
||||
<ConfigurableDashboard
|
||||
config={configuration}
|
||||
data={data}
|
||||
onModeChange={handleModeChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -50,4 +50,5 @@ export interface DashboardConfig {
|
||||
export interface DashboardProps {
|
||||
config: DashboardConfig;
|
||||
data: ReportData;
|
||||
onModeChange?: (state: DashboardState) => void;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ export default function Dashboard(props: DashboardProps) {
|
||||
});
|
||||
|
||||
const toggleMode = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
mode: prev.mode === "expense" ? "income" : "expense",
|
||||
}));
|
||||
setState(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
mode: prev.mode === "expense" ? "income" as const : "expense" as const,
|
||||
};
|
||||
props.onModeChange?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const togglePeriodType = () => {
|
||||
|
||||
@@ -9,15 +9,14 @@ import { ChartDataPoint } from "./HistoryChart.models";
|
||||
// ─── Tab → PeriodKey ─────────────────────────────────────────
|
||||
|
||||
const TAB_TO_KEY: Record<string, PeriodKey> = {
|
||||
Daily: "daily",
|
||||
Weekly: "weekly",
|
||||
Monthly: "monthly",
|
||||
Yearly: "yearly",
|
||||
"Financial Year": "fyly",
|
||||
"All Time": "full",
|
||||
"All Time": "all",
|
||||
};
|
||||
|
||||
export function tabToKey(tab: string): PeriodKey {
|
||||
return TAB_TO_KEY[tab] ?? "full";
|
||||
return TAB_TO_KEY[tab] ?? "all";
|
||||
}
|
||||
|
||||
// ─── Comparison ──────────────────────────────────────────────
|
||||
@@ -27,10 +26,9 @@ function attachComparison(
|
||||
key: PeriodKey
|
||||
): ChartDataPoint[] {
|
||||
const getCompareIndex = (i: number) => {
|
||||
if (key === "daily") return i - 7;
|
||||
if (key === "weekly") return i - 4;
|
||||
if (key === "monthly") return i - 12;
|
||||
if (key === "yearly") return i - 1;
|
||||
if (key === "fyly") return i - 1;
|
||||
return -1;
|
||||
};
|
||||
|
||||
@@ -64,7 +62,7 @@ export function buildChartData(
|
||||
let points: ChartDataPoint[] = merged.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
amount: getAmount(p, mode),
|
||||
amount: getAmount(p),
|
||||
}));
|
||||
|
||||
if (comparison) {
|
||||
|
||||
@@ -35,11 +35,10 @@ export default function HistoryChart(props: HistoryChartProps) {
|
||||
: 1;
|
||||
|
||||
const visibleCountMap = {
|
||||
daily: 7,
|
||||
weekly: 6,
|
||||
monthly: 4,
|
||||
yearly: 4,
|
||||
fyly: 4,
|
||||
full: 4,
|
||||
all: 4,
|
||||
};
|
||||
|
||||
const visibleCount = visibleCountMap[activeDataKey] ?? 4;
|
||||
|
||||
@@ -13,7 +13,6 @@ function extractTransactions(
|
||||
reportData: ReportData,
|
||||
selectedPeriodId: string | null,
|
||||
selectedGroupKey: GroupKey | null,
|
||||
mode: "expense" | "income"
|
||||
): Transaction[] {
|
||||
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
|
||||
if (selectedPeriodId) {
|
||||
@@ -23,20 +22,16 @@ function extractTransactions(
|
||||
|
||||
if (!selected) return [];
|
||||
|
||||
return mode === "expense"
|
||||
? (selected.expenses.transactions || [])
|
||||
: (selected.incomes.transactions || []);
|
||||
return selected.metric.transactions || [];
|
||||
}
|
||||
|
||||
const periods = mergeBucketPeriods(buckets, "full");
|
||||
const periods = mergeBucketPeriods(buckets, "all");
|
||||
|
||||
if (!periods.length) return [];
|
||||
|
||||
const full = periods[0];
|
||||
|
||||
return mode === "expense"
|
||||
? (full.expenses.transactions || [])
|
||||
: (full.incomes.transactions || []);
|
||||
return full.metric.transactions || [];
|
||||
}
|
||||
|
||||
// ─── Main adapter ────────────────────────────────────────────
|
||||
@@ -47,10 +42,9 @@ export function buildLatestItems(
|
||||
selectedGroupKey: GroupKey | null,
|
||||
mode: "expense" | "income"
|
||||
): LatestItem[] {
|
||||
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode);
|
||||
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);
|
||||
|
||||
return txns
|
||||
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurred_at).getTime() -
|
||||
|
||||
@@ -43,17 +43,17 @@ export function extractTopTags(
|
||||
const tags = bucket.group_key.tags;
|
||||
if (!tags || tags.length === 0) continue;
|
||||
|
||||
// Prefer FULL if available
|
||||
const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[];
|
||||
// Prefer ALL if available
|
||||
const allPeriods = (bucket.periods.all || []) as DecoratedPeriod[];
|
||||
|
||||
const periodsToUse = selectedPeriodId
|
||||
? (Object.values(bucket.periods).flat() as DecoratedPeriod[])
|
||||
: fullPeriods;
|
||||
: allPeriods;
|
||||
|
||||
const period = findPeriod(periodsToUse, selectedPeriodId);
|
||||
if (!period) continue;
|
||||
|
||||
const amount = getAmount(period, mode);
|
||||
const amount = getAmount(period);
|
||||
|
||||
for (const tag of tags) {
|
||||
tagMap.set(tag, (tagMap.get(tag) || 0) + amount);
|
||||
|
||||
@@ -2,11 +2,12 @@ import {
|
||||
ReportPeriod,
|
||||
ReportBucket,
|
||||
GroupKey,
|
||||
PeriodType,
|
||||
} from "../features/report";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full";
|
||||
export type PeriodKey = PeriodType;
|
||||
|
||||
export type DecoratedPeriod = ReportPeriod & {
|
||||
id: string;
|
||||
@@ -16,11 +17,10 @@ export type DecoratedPeriod = ReportPeriod & {
|
||||
// ─── Period helpers ───────────────────────────────────────────
|
||||
|
||||
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
|
||||
D: "daily",
|
||||
W: "weekly",
|
||||
M: "monthly",
|
||||
Y: "yearly",
|
||||
FY: "fyly",
|
||||
FULL: "full",
|
||||
ALL: "all",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,19 +29,16 @@ const PREFIX_TO_KEY: Record<string, PeriodKey> = {
|
||||
*/
|
||||
export function periodIdToKey(periodId: string): PeriodKey {
|
||||
const prefix = periodId.split(":")[0];
|
||||
return PREFIX_TO_KEY[prefix] ?? "full";
|
||||
return PREFIX_TO_KEY[prefix] ?? "all";
|
||||
}
|
||||
|
||||
// ─── Metric helpers ───────────────────────────────────────────
|
||||
|
||||
export function getAmount(
|
||||
period: ReportPeriod,
|
||||
mode: "expense" | "income"
|
||||
): number {
|
||||
return mode === "expense" ? period.expenses.sum : period.incomes.sum;
|
||||
export function getAmount(period: ReportPeriod): number {
|
||||
return period.metric.sum;
|
||||
}
|
||||
|
||||
function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) {
|
||||
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
|
||||
const sum = a.sum + b.sum;
|
||||
const count = a.count + b.count;
|
||||
|
||||
@@ -78,14 +75,12 @@ export function mergeBucketPeriods(
|
||||
if (!existing) {
|
||||
map.set(p.id, {
|
||||
...p,
|
||||
expenses: { ...p.expenses },
|
||||
incomes: { ...p.incomes },
|
||||
metric: { ...p.metric },
|
||||
});
|
||||
} else {
|
||||
map.set(p.id, {
|
||||
...existing,
|
||||
expenses: mergeMetric(existing.expenses, p.expenses),
|
||||
incomes: mergeMetric(existing.incomes, p.incomes),
|
||||
metric: mergeMetric(existing.metric, p.metric),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -126,7 +121,7 @@ export function matchesGroupKey(
|
||||
selected: GroupKey
|
||||
): boolean {
|
||||
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 (!(values as string[]).every((v) => bucketValues.includes(v)))
|
||||
return false;
|
||||
|
||||
@@ -6,7 +6,9 @@ export type {
|
||||
ReportData,
|
||||
ReportBucket,
|
||||
ReportPeriod,
|
||||
ReportQuery,
|
||||
GroupKey,
|
||||
PeriodType,
|
||||
} from './report.models'
|
||||
export {
|
||||
prepareReport
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
export interface Payor {
|
||||
id?: string;
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface Payee {
|
||||
type: "merchant" | "person" | "transfer" | "other";
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
number: string;
|
||||
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
|
||||
currency: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
parent_id?: string | null;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
payor: Payor;
|
||||
payee: Payee;
|
||||
amount: number;
|
||||
account: Account;
|
||||
tags: Tag[];
|
||||
occurred_at: Date;
|
||||
occurred_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
@@ -41,12 +52,12 @@ export interface ReportMetric {
|
||||
// Period
|
||||
// -----------------------------
|
||||
|
||||
export interface ReportPeriod {
|
||||
start: Date;
|
||||
end: Date;
|
||||
export type PeriodType = "daily" | "weekly" | "monthly" | "all";
|
||||
|
||||
expenses: ReportMetric;
|
||||
incomes: ReportMetric;
|
||||
export interface ReportPeriod {
|
||||
start: string;
|
||||
end: string;
|
||||
metric: ReportMetric;
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
@@ -54,46 +65,48 @@ export interface ReportPeriod {
|
||||
// -----------------------------
|
||||
|
||||
export type GroupKey = {
|
||||
payee?: string[];
|
||||
tags?: string[];
|
||||
flow?: string[];
|
||||
[dimension: string]: string[];
|
||||
};
|
||||
|
||||
export interface ReportBucket {
|
||||
group_key: GroupKey;
|
||||
|
||||
periods: {
|
||||
daily?: ReportPeriod[];
|
||||
weekly?: ReportPeriod[];
|
||||
monthly?: ReportPeriod[];
|
||||
yearly?: ReportPeriod[];
|
||||
fyly?: ReportPeriod[];
|
||||
full?: ReportPeriod[];
|
||||
all?: 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
|
||||
// -----------------------------
|
||||
|
||||
export interface ReportData {
|
||||
periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
|
||||
snapshot_id?: string | null;
|
||||
|
||||
rolling: boolean;
|
||||
report_date?: string;
|
||||
flow?: "inflows" | "outflows" | null;
|
||||
|
||||
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;
|
||||
min_amount?: number | null;
|
||||
max_amount?: number | null;
|
||||
payee?: string[] | null;
|
||||
|
||||
buckets: ReportBucket[];
|
||||
|
||||
query: ReportQuery;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ReportData,
|
||||
ReportPeriod
|
||||
ReportPeriod,
|
||||
PeriodType,
|
||||
} from "./report.models";
|
||||
|
||||
/* ---------- ID BUILDING ---------- */
|
||||
@@ -13,7 +14,7 @@ function formatDate(d: Date): string {
|
||||
}
|
||||
|
||||
function buildPeriodId(
|
||||
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
|
||||
type: PeriodType,
|
||||
start: Date,
|
||||
end: Date
|
||||
): string {
|
||||
@@ -21,16 +22,14 @@ function buildPeriodId(
|
||||
const e = formatDate(end);
|
||||
|
||||
switch (type) {
|
||||
case "daily":
|
||||
return `D:${s}_${e}`;
|
||||
case "weekly":
|
||||
return `W:${s}_${e}`;
|
||||
case "monthly":
|
||||
return `M:${s}_${e}`;
|
||||
case "yearly":
|
||||
return `Y:${s}_${e}`;
|
||||
case "fyly":
|
||||
return `FY:${s}_${e}`;
|
||||
case "full":
|
||||
return `FULL:${s}_${e}`;
|
||||
case "all":
|
||||
return `ALL:${s}_${e}`;
|
||||
default:
|
||||
return `${s}_${e}`;
|
||||
}
|
||||
@@ -60,19 +59,15 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
function sameMonth(a: Date, b: Date) {
|
||||
return (
|
||||
a.getUTCFullYear() === b.getUTCFullYear() &&
|
||||
a.getUTCMonth() === b.getUTCMonth()
|
||||
);
|
||||
}
|
||||
|
||||
function buildLabel(
|
||||
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
|
||||
type: PeriodType,
|
||||
start: Date,
|
||||
end: Date
|
||||
): string {
|
||||
switch (type) {
|
||||
case "daily":
|
||||
return dayFmt.format(start);
|
||||
|
||||
case "weekly": {
|
||||
const sDay = start.getUTCDate();
|
||||
const m = monthFmt.format(start);
|
||||
@@ -82,15 +77,6 @@ function buildLabel(
|
||||
case "monthly":
|
||||
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:
|
||||
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
|
||||
}
|
||||
@@ -99,7 +85,7 @@ function buildLabel(
|
||||
/* ---------- MAIN ---------- */
|
||||
|
||||
function decoratePeriods(
|
||||
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
|
||||
type: PeriodType,
|
||||
periods: ReportPeriod[]
|
||||
): (ReportPeriod & { id: string; label: string })[] {
|
||||
return periods.map((p) => ({
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useResourceByName } from "../../../react-openapi";
|
||||
|
||||
export interface ReportParams {
|
||||
periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
|
||||
rolling?: boolean;
|
||||
report_date?: string;
|
||||
group_by?: ("payee" | "tags")[];
|
||||
ignore_self?: boolean;
|
||||
include_transactions?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
flow?: "expense" | "income";
|
||||
snapshot_id?: string;
|
||||
periods?: ("daily" | "weekly" | "monthly" | "all")[];
|
||||
flow?: "inflows" | "outflows";
|
||||
payee?: string[];
|
||||
account?: string[];
|
||||
tags?: string[];
|
||||
min_amount?: number;
|
||||
max_amount?: number;
|
||||
}
|
||||
|
||||
export function useReport(params: ReportParams) {
|
||||
@@ -23,6 +14,5 @@ export function useReport(params: ReportParams) {
|
||||
return useList({
|
||||
...params,
|
||||
periods: params.periods,
|
||||
group_by: params.group_by,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user