Build a Finance SaaS Platform -23 (Summary Page)

Install Package

1
2
npm i react-icons
npm i react-countup

Add Summary Hook API

Add features/summary/api/use-get-summary.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { useQuery } from "@tanstack/react-query";

import { client } from "@/lib/hono"
import { useSearchParams } from "next/navigation";
import { convertAmountFromMiliunits } from "@/lib/utils";

export const useGetSummary = () => {
const params = useSearchParams();
const from = params.get("from") || "";
const to = params.get("to") || "";
const accountId = params.get("accountId") || "";

const query = useQuery({

queryKey: ["summary", { from, to, accountId }],
queryFn: async () => {
const res = await client.api.summary.$get({
query: {
from,
to,
accountId
},
});

if(!res.ok){
throw new Error("Failed to fetch summary");
}

const { data } = await res.json();
return {
...data,
incomeAmount: convertAmountFromMiliunits(data.incomeAmount),
expenseAmount: convertAmountFromMiliunits(data.expenseAmount),
remainingAmount: convertAmountFromMiliunits(data.remainingAmount),
categories: data.categories.map((category) =>({
...category,
value: convertAmountFromMiliunits(category.value),
})),
days: data.days.map((day) =>({
...day,
income: convertAmountFromMiliunits(day.income),
expenses: convertAmountFromMiliunits(day.expenses),
}))
}
},
});

return query;
};

Add New Functions

Modify lib/utils.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
...
type Period = {
from: string | Date | undefined;
to: string | Date | undefined;
};

export function formatDateRange( period?: Period) {
const defaultTo = new Date();
const defaultFrom = subDays(defaultTo, 30);

if(!period?.from) {
return `${format(defaultFrom,"LLL dd")} - ${format(defaultTo, "LLL dd,y")}}`;
};

if(period?.to) {
return `${format(period.from,"LLL dd")} - ${format(defaultTo, "LLL dd,y")}}`;
};

return format(period.from, "LLD dd, y");
}

export function formatPercentage(
value: number,
options: { addPrefix?: boolean } = {
addPrefix: false
}
) {
const result = new Intl.NumberFormat("en-US", { style: "percent"}).format(value/100);

if( options.addPrefix && value > 0 ) {
return `+${result}`
}

return result;
}

Add CountUp Component

Add components/count-up.tsx

1
2
3
4
"use client";

import CountUp from "react-countup";
export { CountUp };

Add DataCard Component

Add components/data-card.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import { IconType }  from "react-icons";
import { VariantProps, cva } from "class-variance-authority";

import { Skeleton } from "./ui/skeleton";
import { cn, formatCurrency, formatPercentage } from "@/lib/utils";

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";

import { CountUp } from "@/components/count-up";

const boxVariant = cva(
"shrink-0 rounded-md p-3",
{
variants:{
variant: {
default: "fill-blue-500",
success: "fill-emerald-500",
danger: "fill-rose-500",
warning: "fill-yellow-500",
}
},
defaultVariants: {
variant: "default",
},
},
);

const iconVariant = cva(
"size-6",
{
variants:{
variant: {
default: "fill-blue-500",
success: "fill-emerald-500",
danger: "fill-rose-500",
warning: "fill-yellow-500",
}
},
defaultVariants: {
variant: "default",
},
},
);

type BoxVariants = VariantProps<typeof boxVariant>;
type IconVariants = VariantProps<typeof iconVariant>;

interface DataCardProps extends BoxVariants, IconVariants {
icon: IconType;
title: string;
value?: number;
dateRange: string;
percentageChange?: number;
}

export const DataCard = ({
icon: Icon,
title,
value = 0,
variant,
dateRange,
percentageChange = 0,
}:DataCardProps) => {
return (
<Card className="border-none drop-shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-x-4">
<div className="space-y-2">
<CardTitle className="text-2xl line-clamp-1">
{title}
</CardTitle>
<CardDescription className="line-clamp-1">
{dateRange}
</CardDescription>
</div>
<div className={cn(boxVariant({variant}))}>
<Icon className={cn(iconVariant({ variant }))} />
</div>
</CardHeader>
<CardContent>
<h1 className="font-bold text-2xl mb-2 line-clamp-1 break-all">
<CountUp
preserveValue
start={0}
end={value}
decimals={2}
decimalPlaces={2}
formattingFn={formatCurrency}
/>
</h1>
<p className={cn(
"text-muted-foreground text-sm line-clamp-1",
percentageChange >= 0 && "text-emerald-500",
percentageChange < 0 && "text-rose-500",
)}>
{formatPercentage(percentageChange)} from last period
</p>
</CardContent>
</Card>
)
}

export const DateCardLoading =() => {
return (
<Card className="border-none drop-shadow-sm h-[192px]">
<CardHeader className="flex flex-row items-center justify-between gap-x-4">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="size-12" />
</CardHeader>
<CardContent>
<Skeleton className="shrink-0 h-10 w-24 mb-2" />
<Skeleton className="shrink-0 h-4 w-40" />
</CardContent>
</Card>
);
};

Add DataGrid Component

Add components/data-grid.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"use client";

import { FaPiggyBank } from "react-icons/fa";
import { FaArrowTrendDown, FaArrowTrendUp } from "react-icons/fa6";
import { formatDateRange } from "@/lib/utils";
import { useSearchParams } from "next/navigation"
import { DataCard, DateCardLoading } from "./data-card";
import { useGetSummary } from "@/features/summary/api/use-get-summary";

export const DataGrid = () => {
const { data, isLoading } = useGetSummary();
const params = useSearchParams();
const to = params.get("to") || undefined;
const from = params.get("from") || undefined;

const dateRangeLabel = formatDateRange({ to, from});

if (isLoading) {
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-2 mb-8">
<DateCardLoading />
<DateCardLoading />
<DateCardLoading />
</div>
)
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-2 mb-8">
<DataCard
title="Remaining"
value={data?.remainingAmount}
percentageChange={data?.remainChange}
icon={FaPiggyBank}
variant="default"
dateRange={dateRangeLabel}
/>
<DataCard
title="Income"
value={data?.incomeAmount}
percentageChange={data?.incomeChange}
icon={FaArrowTrendUp}
dateRange={dateRangeLabel}
/>
<DataCard
title="Expense"
value={data?.expenseAmount}
percentageChange={data?.expenseChange}
icon={FaArrowTrendDown}
dateRange={dateRangeLabel}
/>
</div>
)
}

Modify Summary Page

Modify app/(dashboard)/page.tsx

1
2
3
4
5
6
7
8
9
10
11
12
"use client";

import { DataGrid } from "@/components/data-grid";

export default function DashboardPage() {

return (
<div className="max-w-screen-2xl mx-auto w-full pb-10 -mt-24">
<DataGrid />
</div>
);
}

请我喝杯咖啡吧~

支付宝
微信