Build a Finance SaaS Platform -26 (Account/Date Filter)(End)

Install Package

1
npm i query-string

Add Filter

Add components/filters.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'
import { AccountFilter } from '@/components/account-filter'
import { DateFilter } from '@/components/date-filter'

export const Filters = () => {
return (
<div className="flex flex-col lg:flex-row items-center gap-y-2 lg:gap-y-0 lg:gap-x-2">
<AccountFilter />
<DateFilter />
</div>
)
}

More...

Build a Finance SaaS Platform -25 (Spending Pie)

Add Spending Pie Component

Add components/spending-pie.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

import { useState } from "react";
import { FileSearch, Loader2, PieChart, Radar, Target } from "lucide-react";

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"

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

import { PieVariant } from "@/components/pie-variant";
import { RadialVariant } from "@/components/radial-variant";
import { RadarVariant } from "./radar-variant";
import { Skeleton } from "./ui/skeleton";


type Props = {
data?: {
name: string;
value: number;
}[];
};

export const SpendingPie = ( { data = []} : Props) => {
const [chartType, setChartType] = useState("pie");

const onTypeChange = ( type: string) => {
// TODO: Add paywall
setChartType(type);
}
return(
<Card className="border-none drop-shadow-sm">
<CardHeader className="flex space-y-2 lg:space-y-0 lg:flex-row lg:items-center justify-between">
<CardTitle className="text-xl line-clamp-1">
Categories
</CardTitle>
<Select
defaultValue={chartType}
onValueChange={onTypeChange}
>
<SelectTrigger className="lg:w-auto h-9 rounded-md px-3">
<SelectValue placeholder="Chart type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pie">
<div className="flex items-center">
<PieChart className="size-4 mr-2 shrink-0" />
<p className="line-clamp-1">
Pie chart
</p>
</div>
</SelectItem>
<SelectItem value="radar">
<div className="flex items-center">
<Radar className="size-4 mr-2 shrink-0" />
<p className="line-clamp-1">
Radar chart
</p>
</div>
</SelectItem>
<SelectItem value="radial">
<div className="flex items-center">
<Target className="size-4 mr-2 shrink-0" />
<p className="line-clamp-1">
Radial chart
</p>
</div>
</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent>
{data.length === 0 ? (
<div className="felx flex-col gap-y-4 items-center justify-center h-[350px] w-full">
<FileSearch className="size-6 text-muted-foreground" />
<p className="text-muted-foreground text-sm">
No data for this period
</p>
</div>
) : (
<>
{ chartType === "pie" && <PieVariant data={data} /> }
{ chartType === "radar" && <RadarVariant data={data} /> }
{ chartType === "radial" && <RadialVariant data={data} /> }
</>
)}
</CardContent>
</Card>
)
}

export const SpendingPieLoading =() => {
return (
<Card className="border-none drop-shadow-sm">
<CardHeader className="flex space-y-2 lg:space-y-0 lg:flex-row lg:items-center justify-between">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-8 lg:w-[120px] w-full" />
</CardHeader>
<CardContent>
<div className="h-[350px] w-full flex items-center justify-center">
<Loader2 className="h-6 w-6 text-slate-300 animate-spin"/>
</div>
</CardContent>
</Card>
);
};
More...

Build a Finance SaaS Platform -22 (Seed Function)

Add Seed Function

Add scripts/seed.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
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
import { config } from "dotenv";
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { eachDayOfInterval,format, subDays } from "date-fns";

import { categories,accounts,transactions } from "@/db/schema";
import { convertAmountToMilliunits } from "@/lib/utils";

config({ path: ".env.local"});

const sql = postgres(process.env.DATABASE_URL!);
const db = drizzle(sql);

const SEED_USER_ID = "user_2krYMtZimxyJTWl7HDsA1FrAwYC";
const SEED_CATEGORIES = [
{ id: "category_1", name: "Food", userId: SEED_USER_ID, plaidId: null },
{ id: "category_2", name: "Transportation", userId: SEED_USER_ID, plaidId: null },
{ id: "category_3", name: "Utilities", userId: SEED_USER_ID, plaidId: null },
{ id: "category_4", name: "Entertainment", userId: SEED_USER_ID, plaidId: null },
{ id: "category_5", name: "Clothing", userId: SEED_USER_ID, plaidId: null },
{ id: "category_6", name: "Miscellaneous", userId: SEED_USER_ID, plaidId: null },
{ id: "category_7", name: "Rent", userId: SEED_USER_ID, plaidId: null },
];
const SEED_TRANSACTIONS: typeof transactions.$inferInsert[] = [];
const SEED_ACCOUNTS = [
{ id: "account_1", name: "Cash", userId: SEED_USER_ID, plaidId: null},
{ id: "account_2", name: "Saving", userId: SEED_USER_ID, plaidId: null},
];

const defaultTo = new Date();
const defaultFrom = subDays(defaultTo, 90);

const generateRandomAmount = (category: typeof categories.$inferInsert) => {
switch( category.name){
case "Transportation":
case "Miscellaneous":
return Math.random()* 50 + 15;
case "Utilities":
return Math.random()* 200 + 10;
case "Food":
return Math.random()* 30 + 10;
case "Entertainment":
case "Clothing":
return Math.random()* 100 + 10;
case "Rent":
return Math.random()* 400 + 90;
default:
return Math.random()* 50 + 10;
}
}

const generateTransactionsForDay = (day: Date) => {
const numTransactions = Math.floor(Math.random()*9) + 1 // 1 to 9 transactions per day

for(let i = 0; i < numTransactions; i++) {
const category = SEED_CATEGORIES[Math.floor(Math.random()* SEED_CATEGORIES.length)];
const isExpense = Math.random() > 0.6; // 60% chance of being an expense
const amount = generateRandomAmount(category);
const formattedAmount = convertAmountToMilliunits(isExpense ? -amount : amount);

//console.log(i + " " + formattedAmount);

SEED_TRANSACTIONS.push({
id: `transaction_${format(day,"yyyy-MM-dd")}_${i}`,
accountId: SEED_ACCOUNTS[0].id, //Always use first Account
categoryId: category.id,
date: day,
amount: formattedAmount,
payee: "Merchant",
notes: "Random transaction",
})
}
};

const generateTransactions = () => {
const days = eachDayOfInterval( { start: defaultFrom, end: defaultTo });
days.forEach( day => generateTransactionsForDay(day));
}

generateTransactions();

const main = async () =>{
try {
// Reset database
await db.delete(transactions).execute();
await db.delete(accounts).execute();
await db.delete(categories).execute();
// Seed categories
await db.insert(categories).values(SEED_CATEGORIES).execute();
// Seed accounts
await db.insert(accounts).values(SEED_ACCOUNTS).execute();
// Seed transactions
await db.insert(transactions).values(SEED_TRANSACTIONS).execute();

} catch (error) {
console.error("Error during seed:", error);
process.exit(1)
};
};

main();

Add Seed Command:

Modify package.json

1
2
3
4
5
"scripts": {
...
"db:seed": "tsx ./scripts/seed.ts",
...
}

Run Seed

1
npm run db:seed

Build a Finance SaaS Platform -20 (Update Transaction Data)

Add Select Account Componet

Add features/accounts/hooks/use-select-account.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
import { useRef, useState } from "react";

import { useGetAccounts } from "@/features/accounts/api/use-get-accounts";
import { useCreateAccount } from "@/features/accounts/api/use-create-account";

import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";

import { Button } from "@/components/ui/button";
import { Select } from "@/components/select";

export const useSelectAccount = (): [()=> JSX.Element,()=> Promise<unknown>] => {
const accountQuery = useGetAccounts();
const accountMutation = useCreateAccount();
const onCreateAccount = (name: string)=> accountMutation.mutate({
name
});

const accountOptions = (accountQuery.data ?? []).map((account) =>({
label: account.name,
value: account.id,
}));

const [promise, setPromise] = useState<{ resolve:(value: string | undefined)=> void} | null>(null);

const selectValue = useRef<string>();

const confirm = () => new Promise((resolve, reject) =>{
setPromise({ resolve });
})

const handleClose = () => {
setPromise(null);
}

const handleConfirm = ()=> {
promise?.resolve(selectValue.current);
handleClose();
}

const handleCancel = () => {
promise?.resolve(undefined);
handleClose();
}

const ComfirmationDialog = () => (
<Dialog open={promise != null}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Select Account
</DialogTitle>
<DialogDescription>
Please select an account to continue.
</DialogDescription>
</DialogHeader>
<Select
placeholder="Select an account"
options={accountOptions}
onCreate={onCreateAccount}
onChange={(value) => selectValue.current = value}
disabled={accountQuery.isLoading || accountMutation.isPending}
/>
<DialogFooter className="pt-2">
<Button
onClick={handleCancel}
variant="outline"
>
Cancel
</Button>
<Button onClick={handleConfirm}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);

return [ComfirmationDialog, confirm];
}
More...

Build a Finance SaaS Platform -17 (Transaction Page)

Add Transaction Page

Add app/(dashboard)/transactions/page.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
"use client";

import { Loader2, Plus } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";

import { columns } from "./columns";
import { DataTable } from "@/components/data-table";
import { useNewTransaction } from "@/features/transactions/hooks/use-new-transaction";
import { useBulkDeleteTransactions } from "@/features/transactions/api/use-bulk-delete-transactions";
import { useGetTransactions } from "@/features/transactions/api/use-get-transactions";

const TransactionsPage = ()=> {
const newTransaction = useNewTransaction();
const deleteTransaction = useBulkDeleteTransactions();
const TransactionQuery = useGetTransactions();
const Transactions = TransactionQuery.data || [];

const isDisabled = TransactionQuery.isLoading || deleteTransaction.isPending;

if( TransactionQuery.isLoading) {
return (
<div className="max-w-screen-2xl max-auto w-full pb-10 -mt-24">
<Card className="border-none drop-shadow-sm">
<CardHeader>
<Skeleton className="h-8 w-48" />
</CardHeader>
<CardContent>
<div className="h-[500px] w-full flex items-center">
<Loader2 className="size-6 text-slate-300 animate-spin" />
</div>
</CardContent>
</Card>
</div>
)
};

return (
<div className="max-w-screen-2xl mx-auto w-full pb-10 -mt-24">
<Card className="border-none drop-shadow-sm">
<CardHeader className="gap-y-2 lg:flex-row lg:items-center lg:justify-between">
<CardTitle className="text-xl line-clamp-1">
Transaction History
</CardTitle>
<Button onClick={newTransaction.onOpen} size="sm">
<Plus className="size-4 mr-2" />
Add New
</Button>
</CardHeader>
<CardContent>
<DataTable
filterKey = "name"
columns={columns}
data={ Transactions }
onDelete={(rows)=>{
const ids = rows.map((r) => r.id)
deleteTransaction.mutate({ ids });
}}
disabled={isDisabled}
/>
</CardContent>
</Card>
</div>
);
};

export default TransactionsPage;
More...

请我喝杯咖啡吧~

支付宝
微信