Build a Finance SaaS Platform -18 (Transaction Sheet)

Add Transaction Sheet

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
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);

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 Edit and New Sheet:

Add features/transactions/components/edit-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
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
import { z } from "zod";
import { Loader2 } from "lucide-react";

import { insertTransactionSchema } from "@/db/schema";
import { useGetTransaction } from "@/features/transactions/api/use-get-transaction";
import { useEditTransaction } from "@/features/transactions/api/use-edit-transaction";
import { useDeleteTransaction } from "@/features/transactions/api/use-delete-transaction";

import { useOpenTransaction } from "@/features/transactions/hooks/use-open-transaction";

import { TransactionForm } from "@/features/transactions/components/transaction-form";

import { useConfirm } from "@/hooks/use-confirm";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle } from "@/components/ui/sheet";

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

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

type FormValues = z.input<typeof formSchema>;

export function EditTransactionSheet() {
const { open, onClose,id } = useOpenTransaction();

const [ConfirmDialog, confirm] = useConfirm(
"Are you sure?",
"You are about to delete this transaction"
);

const transactionQuery = useGetTransaction(id);
const editMutation = useEditTransaction(id);
const deleteMutation = useDeleteTransaction(id);

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 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 isPending =
editMutation.isPending ||
deleteMutation.isPending ||
transactionQuery.isPending ||
categoryMutation.isPending ||
accountMutation.isPending;

const isLoading =
transactionQuery.isLoading ||
categoryQuery.isLoading ||
accountQuery.isLoading;

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

const onDelete = async () => {
const ok = await confirm();

if(ok) {
deleteMutation.mutate(undefined,{
onSuccess:() => {
onClose();
}
});
}
}

const defaultValues = transactionQuery.data? {
accountId: transactionQuery.data.accountId,
categoryId: transactionQuery.data.categoryId,
amount: transactionQuery.data.amount.toString(),
date: transactionQuery.data.date ? new Date(transactionQuery.data.date) : new Date(),
payee: transactionQuery.data.payee,
notes: transactionQuery.data.notes,
} : {
accountId: "",
categoryId: "",
amount: "",
date: new Date(),
payee: "",
notes: "",
};

return (
<>
<ConfirmDialog />
<Sheet open={open} onOpenChange={onClose}>
<SheetContent className="space-y-4">
<SheetHeader>
<SheetTitle>Edit Transaction</SheetTitle>
<SheetDescription>
Edit an existing transaction.
</SheetDescription>
</SheetHeader>
{isLoading ? (
<div className="absolute inset-0 flex item-center justify-center">
<Loader2 className="size-4 text-muted-foreground animate-spin" />
</div>
):(
<TransactionForm
id={id}
defaultValues={defaultValues}
onSubmit={onSubmit}
disabled={isPending}
categoryOptions={categoryOptions}
onCreateCategory={onCreateCategory}
accountOptions={accountOptions}
onCreateAccount={onCreateAccount}
/>
)}
</SheetContent>
</Sheet>
</>
);
}

Add app/(dashboard)/transactions/category-column.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
import { TriangleAlert } from "lucide-react";

import { useOpenCategory } from "@/features/categories/hooks/use-open-category";
import { useOpenTransaction } from "@/features/transactions/hooks/use-open-transaction";

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

type Props = {
id: string;
category: string | null;
categoryId: string| null;
}

export const CategoryColumn = ({ id, category, categoryId }: Props) =>{
const { onOpen: onOpenCategory } = useOpenCategory();
const { onOpen: onOpenTransaction } = useOpenTransaction();

const onClick = () => {
if (categoryId){
onOpenCategory(categoryId);
} else {
onOpenTransaction(id);
}
};

return (
<div
onClick={onClick}
className={cn("flex items-center course-pointer hover:underline",
!category && "text-rose-500",
)}
>
{!category && <TriangleAlert className="mr-2 size-4 shrink-0" />}
{category || "Uncategorized"}
</div>
)
}

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 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 isPending =
createMutation.isPending ||
categoryMutation.isPending ||
accountMutation.isPending;

const isLoading =
categoryQuery.isLoading ||
accountQuery.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 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 }),
}));

Add 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 }),
}));

请我喝杯咖啡吧~

支付宝
微信