Build a Finance SaaS Platform -15(Transaction Form)

Install Package:

1
2
3
4
5
6
7
8
9
npm i react-select
npm i date-fns@3.0.0
npm i react-day-picker
npm i react-currency-input-field

npx shadcn@latest add calender
npx shadcn@latest add popover
npx shadcn@latest add textarea
npx shadcn@latest add tooltip

Add Sheet Hook

Add features/transactions/hooks/use-new-transaction.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { create } from "zustand";

type NewTransactionStore = {
open: boolean;
onOpen: () => void;
onClose: () => void;
};

export const useNewTransaction = create<NewTransactionStore>((set) => ({
open: false,
onOpen: () => set({ open: true }),
onClose: () => set({ open: false }),
}));

features/transactions/hooks/use-open-transaction.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { create } from "zustand";

type OpenTransactionState = {
id?: string;
open: boolean;
onOpen: (id: string) => void;
onClose: () => void;
};

export const useOpenTransaction = create<OpenTransactionState>((set) => ({
id: undefined,
open: false,
onOpen: (id: string) => set({ open: true, id }),
onClose: () => set({ open: false, id:undefined }),
}));

Add Customizing Components

Add components/date-picker.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
import * as React from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { SelectSingleEventHandler } from "react-day-picker";

import { cn } from "@/lib/utils";

import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";

type Props = {
value?: Date;
onChange?: SelectSingleEventHandler;
disabled?: boolean;
}

export const DatePicker = ({
value,
onChange,
disabled,
}: Props) => {
return (
<Popover>
<PopoverTrigger asChild>
<Button
disabled={disabled}
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
)}
>
<CalendarIcon className="size-4 mr-2" />
{value ? format(value, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent>
<Calendar
mode="single"
selected={value}
onSelect={onChange}
disabled={disabled}
initialFocus
/>
</PopoverContent>
</Popover>
);
}

Add components/amount-input.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
import CurrencyInput from "react-currency-input-field";
import { Info, MinusCircle, PlusCircle } from "lucide-react";

import { cn } from "@/lib/utils";

import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";

type Props = {
value: string;
onChange: (value: string | undefined) => void;
placeholder?: string;
disabled?: boolean;
};

export const AmountInput = ({
value,
onChange,
placeholder,
disabled,
}: Props) => {
const parsedValue = parseFloat(value);
const isIncome = parsedValue > 0;
const isExpense = parsedValue < 0;

const onReverseValue = ()=>{
if (!value) return;
onChange((parseFloat(value) * -1).toString());
}
return(
<div className="relative">
<TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onReverseValue}
className={cn(
"bg-slate-400 hover:bg-slate-500 absolute top-1.5 left-1.5 rounded-md p-2 flex items-center justify-center transition",
isIncome && "bg-emerald-500 hover:bg-emerald-600",
isExpense && "bg-rose-500 hover:bg-rose-600"
)}
>
{!parsedValue && <Info className="size-3 text-white" />}
{isIncome && <PlusCircle className="size-3 text-white"/>}
{isExpense && <MinusCircle className="size-3 text-white" />}
</button>
</TooltipTrigger>
<TooltipContent>
Use [+] for income and [-] for expense
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CurrencyInput
prefix="$"
className="pl-10 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={placeholder}
value={value}
decimalsLimit={2}
decimalScale={2}
onValueChange={onChange}
disabled={disabled}
/>
<p>
{isIncome && "This will count as income"}
{isExpense && "This will count as expense"}
</p>
</div>
)
}

Add components/select.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
"use client";

import { useMemo } from "react";
import { SingleValue } from "react-select";
import CreatableSelect from "react-select/creatable";

type Props = {
onChange: (value?: string) => void;
onCreate?: (value: string) => void;
options?: {label: string; value: string}[];
value?: string | null | undefined;
disabled?: boolean;
placeholder?: string;
}

export const Select = ({
value,
onChange,
onCreate,
disabled,
options = [],
placeholder,
}: Props) => {
const onSelect = (
option: SingleValue<{ label:string, value:string }>
) => {
onChange(option?.value);
}

const formattedValue = useMemo(()=> {
return options.find((option) => option.value === value );
}, [options, value])

return (
<CreatableSelect
placeholder={placeholder}
className="text-sm h-10"
styles={{
control: (base)=>({
...base,
borderColor: "e2e8f0",
":hover": {
borderColor: "e2e8f0"
}
})
}}
value={formattedValue}
onChange={onSelect}
options={options}
onCreateOption={onCreate}
isDisabled={disabled}
/>
)
}

Add Transaction Form

Add features/transactions/components/transaction-form.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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
import { z } from "zod";
import { insertTransactionSchema } from "@/db/schema";
import { Trash } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select } from "@/components/select";
import { DatePicker } from "@/components/date-picker";
import { AmountInput } from "@/components/amount-input";

import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { convertAmountToMilliunits } from "@/lib/utils";

const formSchema = z.object({
date: z.coerce.date(),
accountId: z.string(),
categoryId: z.string().nullable().optional(),
payee: z.string(),
amount: z.string(),
notes: z.string().nullable().optional(),
})

const apiSchema =insertTransactionSchema.omit({
id: true,
});

type FormValues = z.input<typeof formSchema>;
type ApiFormValues = z.input<typeof apiSchema>;

type Props = {
id?: string;
defaultValues?: FormValues;
onSubmit: (values: ApiFormValues) => void;
onDelete?: () => void;
disabled?: boolean;
accountOptions: { label: string; value: string;}[];
categoryOptions: { label: string; value: string;}[];
onCreateAccount: (name: string) => void;
onCreateCategory: (name:string) => void;
};

export const TransactionForm = ({
id,
defaultValues,
onSubmit,
onDelete,
disabled,
accountOptions,
categoryOptions,
onCreateAccount,
onCreateCategory,
}: Props) => {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
});

const handleSubmit = (values: FormValues) => {
const amount = parseFloat(values.amount);
const amountInMiliunits = convertAmountToMilliunits(amount);

//console.log({values});
onSubmit({
...values,
amount: amountInMiliunits,
})
};

const handleDelete = () => {
onDelete?.();
};

return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 pt-4"
>
<FormField
name="date"
control={form.control}
render={({ field }) => (
<FormItem>
<FormControl>
<DatePicker
value={field.value}
onChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountId"
render={({ field }) => (
<FormItem>
<FormLabel>
Acount
</FormLabel>
<FormControl>
<Select
placeholder="Select an account"
options={accountOptions}
onCreate={onCreateAccount}
value={field.value}
onChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>
Category
</FormLabel>
<FormControl>
<Select
placeholder="Select an category"
options={categoryOptions}
onCreate={onCreateCategory}
value={field.value}
onChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
name="payee"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>
Payee
</FormLabel>
<FormControl>
<Input
disabled={disabled}
placeholder="Add a payee"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
name="amount"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>
Amount
</FormLabel>
<FormControl>
<AmountInput
{...field}
disabled={disabled}
placeholder="0.00"
/>
</FormControl>
</FormItem>
)}
/>
<FormField
name="notes"
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>
Notes
</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value ?? ""}
disabled={disabled}
placeholder="Optional notes"
/>
</FormControl>
</FormItem>
)}
/>
<Button className="w-full" disabled={disabled}>
{id ? "Save Changes":"Create Transaction" }
</Button>
{!!id && (
<Button
type="button"
disabled={disabled}
onClick={handleDelete}
className="w-full"
variant="outline"
>
<Trash className="size-4 mr-3"/>
Delete Transaction
</Button>
)}
</form>
</Form>
)
};

Add New Transaction Sheet

Add features/transactions/components/new-transaction-sheet.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
import { z } from "zod";
import { insertTransactionSchema } from "@/db/schema";
import { useNewTransaction } from "@/features/transactions/hooks/use-new-transaction";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle } from "@/components/ui/sheet";

import { useCreateTransaction } from "@/features/transactions/api/use-create-transaction";
import { useGetCategories } from "@/features/categories/api/use-get-categories";
import { useCreateCategory } from "@/features/categories/api/use-create-category";
import { useCreateAccount } from "@/features/accounts/api/use-create-account";
import { useGetAccounts } from "@/features/accounts/api/use-get-accounts";
import { TransactionForm } from "./transaction-form";
import { Loader2 } from "lucide-react";

const formSchema = insertTransactionSchema.omit({
id: true,
});

type FormValues = z.input<typeof formSchema>;

export function NewTransactionSheet() {
const { open, onClose } = useNewTransaction();

const createMutation = useCreateTransaction();

const categoryQuery = useGetCategories()
const categoryMutation = useCreateCategory();
const onCreateCategory = (name: string) => categoryMutation.mutate({
name
});
const categoryOptions = (categoryQuery.data ?? []).map((category) => ({
label: category.name,
value: category.id,
}))

const accoutQuery = useGetAccounts()
const accountMutation = useCreateAccount();
const onCreateAccount = (name: string) => accountMutation.mutate({
name
});
const accountOptions = (accoutQuery.data ?? []).map((account)=> ({
label: account.name,
value: account.id,
}))

const isPending =
createMutation.isPending ||
categoryMutation.isPending ||
accountMutation.isPending;

const isLoading =
categoryQuery.isLoading ||
accoutQuery.isLoading;

const onSubmit = (values: FormValues)=> {
createMutation.mutate(values,{
onSuccess: ()=>{
onClose();
}
});
};

return (
<Sheet open={open} onOpenChange={onClose}>
<SheetContent className="space-y-4">
<SheetHeader>
<SheetTitle>New Transaction</SheetTitle>
<SheetDescription>
Create a new transaction to track your finances.
</SheetDescription>
</SheetHeader>
{isLoading
? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="size-4 text-muted-foreground animate-spin" />
</div>
)
:(
<TransactionForm
onSubmit={onSubmit}
disabled={isPending}
categoryOptions={categoryOptions}
onCreateCategory={onCreateCategory}
accountOptions={accountOptions}
onCreateAccount={onCreateAccount}
/>
)
}
</SheetContent>
</Sheet>
);
}

Add Some Functions

Modify lib/utils.ts

1
2
3
4
5
6
7
...
export function convertAmountFromMiliunits(amount:number) {
return amount / 1000;
}
export function convertAmountToMilliunits(amount:number){
return Math.round( amount * 1000);
}

请我喝杯咖啡吧~

支付宝
微信