From ba5e7114d5337195507d36441b2552b650adcd4d Mon Sep 17 00:00:00 2001 From: Alex Ghirelli Date: Fri, 22 Mar 2024 23:14:49 +0100 Subject: [PATCH 1/4] feat: stitch transaction modal to apis --- .../_components/transactions-dashboard.tsx | 22 +-- .../www/components/forms/transaction-form.tsx | 163 +++++++++--------- .../components/transactions-dashboard.tsx | 14 -- apps/www/lib/utils.ts | 9 - packages/api/src/edge.ts | 2 + packages/api/src/router/account.ts | 17 +- packages/api/src/router/asset.ts | 16 +- packages/api/src/router/transaction.ts | 38 ++++ packages/validators/src/index.ts | 15 ++ 9 files changed, 169 insertions(+), 127 deletions(-) create mode 100644 packages/api/src/router/transaction.ts diff --git a/apps/www/app/(dashboard)/(workspaceId)/banking/transactions/_components/transactions-dashboard.tsx b/apps/www/app/(dashboard)/(workspaceId)/banking/transactions/_components/transactions-dashboard.tsx index f24ea56b..ab278655 100644 --- a/apps/www/app/(dashboard)/(workspaceId)/banking/transactions/_components/transactions-dashboard.tsx +++ b/apps/www/app/(dashboard)/(workspaceId)/banking/transactions/_components/transactions-dashboard.tsx @@ -57,25 +57,9 @@ export function TransactionsDashboard({ -
- - - All transactions - - - Unread - - - - - -
+ + + diff --git a/apps/www/components/forms/transaction-form.tsx b/apps/www/components/forms/transaction-form.tsx index bc76caec..f3bac8a7 100644 --- a/apps/www/components/forms/transaction-form.tsx +++ b/apps/www/components/forms/transaction-form.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { use, useState } from "react"; +import { api } from "@/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { format } from "date-fns"; @@ -6,7 +7,9 @@ import { CalendarIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { cn, formatNumberWithSpaces } from "@/lib/utils"; +import { createTransactionSchema } from "@projectx/validators"; + +import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; import { Calendar } from "../ui/calendar"; @@ -28,92 +31,87 @@ import { } from "../ui/form"; import { Input } from "../ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; - -const bankAccounts = [ - { label: "Chase", value: "chase" }, - { label: "Wells Fargo", value: "wells_fargo" }, - { label: "Bank of America", value: "bank_of_america" }, - { label: "Citi", value: "citi" }, - { label: "PNC", value: "pnc" }, - { label: "TD", value: "td" }, -]; - -const FormSchema = z.object({ - purchaseDate: z.date({ - required_error: "Please enter a purchase date", - }), - purchaseValue: z.string({ - required_error: "Please enter a purchase value", - }), - bankAccount: z.string({ - required_error: "Please select a bank account", - }), - category: z.string({ - required_error: "Please select a category", - }), -}); - -type FormFields = keyof z.infer; +import { toast } from "../ui/use-toast"; const TransactionForm = () => { const [isCategoryPopoverOpen, setIsCategoryPopoverOpen] = useState(false); - const form = useForm>({ - resolver: zodResolver(FormSchema), - defaultValues: { - purchaseDate: new Date(), - purchaseValue: "", - bankAccount: "", - category: "", - }, + const currencies = use(api.currency.findAll.query()); + const accounts = use(api.account.listAccounts.query()); + const assets = use(api.asset.listAssets.query()); + + const assetsAndAccounts = [ + ...accounts.map((account) => ({ + label: account.name, + value: account.id, + })), + ...assets.map((asset) => ({ + label: asset.name, + value: asset.id, + })), + ]; + + const form = useForm>({ + resolver: zodResolver(createTransactionSchema), + defaultValues: {}, }); - const [transactionCategories, setTransactionCategories] = useState([ - { label: "Groceries", value: "groceries" }, - { label: "Utilities", value: "utilities" }, - { label: "Entertainment", value: "entertainment" }, - ]); + // const [transactionCategories, setTransactionCategories] = useState([ + // { label: "Groceries", value: "groceries" }, + // { label: "Utilities", value: "utilities" }, + // { label: "Entertainment", value: "entertainment" }, + // ]); - const [categorySearchQuery, setCategorySearchQuery] = useState(""); + // const [categorySearchQuery, setCategorySearchQuery] = useState(""); - const noCategoriesFound = - transactionCategories.filter((category) => - category.label.includes(categorySearchQuery), - ).length === 0 && categorySearchQuery !== ""; + // const noCategoriesFound = + // transactionCategories.filter((category) => + // category.label.includes(categorySearchQuery), + // ).length === 0 && categorySearchQuery !== ""; - // TODO: make this more reusable - const handleNumberChange = (name: FormFields, value: string) => { - const unformattedValue = value.replace(/\s/g, ""); - form.setValue(name, unformattedValue); - }; + // const handleCategorySelect = (value: string) => { + // form.setValue("category", value); + // setIsCategoryPopoverOpen(false); + // }; - const handleCategorySelect = (value: string) => { - form.setValue("category", value); - setIsCategoryPopoverOpen(false); - }; + // const onAddNewCategoryHandler = () => { + // setTransactionCategories([ + // ...transactionCategories, + // { label: categorySearchQuery, value: categorySearchQuery }, + // ]); + // form.setValue("category", categorySearchQuery); + // setCategorySearchQuery(""); + // setIsCategoryPopoverOpen(false); + // }; - const onAddNewCategoryHandler = () => { - setTransactionCategories([ - ...transactionCategories, - { label: categorySearchQuery, value: categorySearchQuery }, - ]); - form.setValue("category", categorySearchQuery); - setCategorySearchQuery(""); - setIsCategoryPopoverOpen(false); - }; + const onSubmit = async (data: z.infer) => { + const transaction = await api.transaction.addTransaction + .mutate(data) + .catch(() => ({ success: false as const })); + + if (!transaction.success) { + return toast({ + title: "Failed to add your account", + description: "Please try again later.", + }); + } - const onSubmitHandler = (data: z.infer) => { - console.log("Submitting data", data); + toast({ + title: "Account added with success!", + description: "Your account was added with success.", + }); + + form.reset(); }; return (
- + {/* Bank Account Field */} ( Bank Account @@ -129,8 +127,8 @@ const TransactionForm = () => { )} > {field.value - ? bankAccounts.find( - (bank) => bank.value === field.value, + ? assetsAndAccounts.find( + (data) => data.value === field.value, )?.label : "Select bank account"} @@ -145,19 +143,19 @@ const TransactionForm = () => { /> No bank accounts found. - {bankAccounts.map((bank) => ( + {assetsAndAccounts.map((data) => ( { - form.setValue("bankAccount", bank.value); + form.setValue("accountId", data.value); }} > - {bank.label} + {data.label} { name="purchaseDate" render={({ field }) => ( - Purchase Date + Date @@ -223,22 +221,22 @@ const TransactionForm = () => { name="purchaseValue" render={({ field }) => ( - Purchase Value + Amount handleNumberChange("purchaseValue", e.target.value) } - placeholder="Purchase Value..." + placeholder="Insert the amount..." /> )} /> {/* Category Field */} - ( @@ -317,14 +315,13 @@ const TransactionForm = () => { ))} )} - {/* No categories found. */} )} - /> + /> */} ); diff --git a/apps/www/components/transactions/components/transactions-dashboard.tsx b/apps/www/components/transactions/components/transactions-dashboard.tsx index e4aff0bc..077f79a3 100644 --- a/apps/www/components/transactions/components/transactions-dashboard.tsx +++ b/apps/www/components/transactions/components/transactions-dashboard.tsx @@ -262,20 +262,6 @@ export function TransactionsDashboard({

Transactions

- - - All transactions - - - Unread - - diff --git a/apps/www/lib/utils.ts b/apps/www/lib/utils.ts index 61f5188f..04be06c3 100644 --- a/apps/www/lib/utils.ts +++ b/apps/www/lib/utils.ts @@ -121,12 +121,3 @@ export async function fetchGithubData() { throw error; } } - -export const formatNumberWithSpaces = (value: number | string) => { - if (!value) return value; - if (typeof value === "string") { - return value.replace(/\B(?=(\d{3})+(?!\d))/g, " "); - } - return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); -}; - diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts index ffe9d8e9..fcf12bc7 100644 --- a/packages/api/src/edge.ts +++ b/packages/api/src/edge.ts @@ -4,6 +4,7 @@ import { authRouter } from "./router/auth"; import { customerRouter } from "./router/customer"; import { organizationsRouter } from "./router/organizations"; import { stripeRouter } from "./router/stripe"; +import { transactionRouter } from "./router/transaction"; import { usersRouter } from "./router/users"; import { createTRPCRouter } from "./trpc"; @@ -16,4 +17,5 @@ export const edgeRouter = createTRPCRouter({ users: usersRouter, account: accountRouter, asset: assetRouter, + transaction: transactionRouter, }); diff --git a/packages/api/src/router/account.ts b/packages/api/src/router/account.ts index 7dab730d..981b2c7a 100644 --- a/packages/api/src/router/account.ts +++ b/packages/api/src/router/account.ts @@ -1,4 +1,5 @@ -import { db, schema, sql } from "@projectx/db"; +import { db, eq, schema, sql } from "@projectx/db"; +import { account } from "@projectx/db/schema/openbanking"; import { createAccountSchema } from "@projectx/validators"; import { createTRPCRouter, protectedProcedure } from "../trpc"; @@ -63,4 +64,18 @@ export const accountRouter = createTRPCRouter({ return { success: true, assetId: accountQuery.insertId }; }), + + listAccounts: protectedProcedure.query(async (opts) => { + const { userId } = opts.ctx.auth; + + return await opts.ctx.db + .select({ + id: schema.account.id, + name: schema.account.name, + type: schema.account.accountType, + }) + .from(schema.account) + .where(eq(schema.account.userId, userId)) + .execute(); + }), }); diff --git a/packages/api/src/router/asset.ts b/packages/api/src/router/asset.ts index 74f58d47..d25dfe02 100644 --- a/packages/api/src/router/asset.ts +++ b/packages/api/src/router/asset.ts @@ -1,4 +1,4 @@ -import { AssetType, db, schema, sql } from "@projectx/db"; +import { AssetType, db, eq, schema, sql } from "@projectx/db"; import { createAssetSchema, createRealEstateSchema, @@ -83,4 +83,18 @@ export const assetRouter = createTRPCRouter({ return { success: true }; }), + + listAssets: protectedProcedure.query(async (opts) => { + const { userId } = opts.ctx.auth; + + return await opts.ctx.db + .select({ + id: schema.asset.id, + name: schema.asset.name, + type: schema.asset.assetType, + }) + .from(schema.asset) + .where(eq(schema.asset.userId, userId)) + .execute(); + }), }); diff --git a/packages/api/src/router/transaction.ts b/packages/api/src/router/transaction.ts new file mode 100644 index 00000000..b5392406 --- /dev/null +++ b/packages/api/src/router/transaction.ts @@ -0,0 +1,38 @@ +import { db, schema, sql } from "@projectx/db"; +import { + createAccountSchema, + createTransactionSchema, +} from "@projectx/validators"; + +import { createTRPCRouter, protectedProcedure } from "../trpc"; + +export const transactionRouter = createTRPCRouter({ + addTransaction: protectedProcedure + .input(createAccountSchema) + .mutation(async (opts: any) => { + const transactionQuery = await opts.ctx.db + .insert(schema.transaction) + .values({ + accountId: createTransactionSchema.parse(opts.ctx.input.accountId), + assetId: createTransactionSchema.parse(opts.ctx.input.assetId), + currencyIso: createTransactionSchema.parse( + opts.ctx.input.currencyIso, + ), + originalId: createTransactionSchema.parse(opts.ctx.input.originalId), + amount: createTransactionSchema.parse(opts.ctx.input.amount) ?? 0, + date: createTransactionSchema.parse(opts.ctx.input.date), + description: createTransactionSchema.parse( + opts.ctx.input.description, + ), + originalPayload: createTransactionSchema.parse( + opts.ctx.input.originalPayload, + ), + }); + + if (transactionQuery.rowsAffected === 0) { + return { success: false }; + } + + return { success: true }; + }), +}); diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index 6096d2dd..49cf9e02 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -105,3 +105,18 @@ export const createRealEstateSchema = z.object({ currentValue: z.coerce.number().min(0).optional(), }); export type CreateRealEstate = z.infer; + +export const createTransactionSchema = z.object({ + assetId: z.bigint().optional(), + accountId: z.bigint().optional(), + name: z.string().min(1), + address: z.string().min(1), + city: z.string().min(1), + state: z.string().min(1), + postalCode: z.string().min(1), + purchaseDate: z.date(), + currencyIso: z.string().min(2).max(3), + purchaseValue: z.coerce.number().min(1), + currentValue: z.coerce.number().min(0).optional(), +}); +export type CreateTransaction = z.infer; From 201ce0cbcd96a901c6c8cae448c7a863fc2d3a21 Mon Sep 17 00:00:00 2001 From: Alex Ghirelli Date: Mon, 25 Mar 2024 08:02:50 +0100 Subject: [PATCH 2/4] feat: improve transaction from, query and db --- .../www/components/forms/transaction-form.tsx | 596 ++++++++++++------ .../www/components/modals/add-transaction.tsx | 3 - packages/api/src/router/transaction.ts | 39 +- packages/db/src/enum.ts | 24 + packages/db/src/schema/openbanking.ts | 26 +- packages/validators/src/index.ts | 19 +- 6 files changed, 460 insertions(+), 247 deletions(-) diff --git a/apps/www/components/forms/transaction-form.tsx b/apps/www/components/forms/transaction-form.tsx index f3bac8a7..6841c814 100644 --- a/apps/www/components/forms/transaction-form.tsx +++ b/apps/www/components/forms/transaction-form.tsx @@ -3,10 +3,11 @@ import { api } from "@/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { format } from "date-fns"; -import { CalendarIcon } from "lucide-react"; +import { CalendarIcon, Check, ChevronsUpDown } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { TransactionType } from "@projectx/db"; import { createTransactionSchema } from "@projectx/validators"; import { cn } from "@/lib/utils"; @@ -15,7 +16,6 @@ import { Button } from "../ui/button"; import { Calendar } from "../ui/calendar"; import { Command, - CommandAddNew, CommandEmpty, CommandGroup, CommandInput, @@ -31,30 +31,43 @@ import { } from "../ui/form"; import { Input } from "../ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { ScrollArea } from "../ui/scroll-area"; +import { Textarea } from "../ui/textarea"; import { toast } from "../ui/use-toast"; const TransactionForm = () => { - const [isCategoryPopoverOpen, setIsCategoryPopoverOpen] = - useState(false); + // const [isCategoryPopoverOpen, setIsCategoryPopoverOpen] = + // useState(false); const currencies = use(api.currency.findAll.query()); const accounts = use(api.account.listAccounts.query()); const assets = use(api.asset.listAssets.query()); + const transactionType = Object.values(TransactionType); const assetsAndAccounts = [ ...accounts.map((account) => ({ label: account.name, - value: account.id, + value: account.id.toString(), + type: "account", })), ...assets.map((asset) => ({ label: asset.name, - value: asset.id, + value: asset.id.toString(), + type: "asset", })), ]; const form = useForm>({ resolver: zodResolver(createTransactionSchema), - defaultValues: {}, + defaultValues: { + account: undefined, + assetId: undefined, + accountId: undefined, + currencyIso: "", + amount: 0, + date: new Date(), + description: "", + }, }); // const [transactionCategories, setTransactionCategories] = useState([ @@ -106,224 +119,393 @@ const TransactionForm = () => { }; return ( -
- - {/* Bank Account Field */} - ( - - Bank Account - - - - - - - - - - No bank accounts found. - - {assetsAndAccounts.map((data) => ( - { - form.setValue("accountId", data.value); - }} - > - {data.label} - - - ))} - - - - - - - )} - /> - {/* Purchase Date Field */} -
- ( - - Date - + <> + {assetsAndAccounts.length !== 0 ? ( + + + {/* Bank Account Field */} + ( + + Bank Account - - - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> + + + + No bank accounts found. + + {assetsAndAccounts.map((data) => ( + { + form.setValue("account", data.value); + + if (data.type === "asset") { + form.setValue("assetId", data.value); + } + + if (data.type === "account") { + form.setValue("accountId", data.value); + } + + console.log(data.type); + }} + > + {data.label} + + + ))} + + - - - )} - /> -
- {/* Purchase Value Field */} - ( - - Amount - - - handleNumberChange("purchaseValue", e.target.value) - } - placeholder="Insert the amount..." + + + )} + /> + {/* Purchase Date Field */} +
+ ( + + Date + + + + + + + + + + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> +
+
+
+ ( + + Currency + + + + + + + + + + + No currency found. + + + {currencies.map((currency) => ( + { + form.setValue( + "currencyIso", + currency.iso, + ); + }} + > + + {currency.symbol} + + ))} + + + + + + + + )} + /> +
+
+ ( + + Transaction Type + + + + + + + + + + No type found. + + + {transactionType.map((data) => ( + { + form.setValue("type", data); + }} + > + {data} + + + ))} + + + + + + + + )} /> - - - )} - /> - {/* Category Field */} - {/* ( - - Category - - setIsCategoryPopoverOpen(!isCategoryPopoverOpen) - } - > - +
+
+ + {/* Purchase Value Field */} + ( + + Amount - + - - - - - setCategorySearchQuery(newValue) - } - onKeyDown={(e) => { - if ( - e.key === "Enter" && - categorySearchQuery !== "" && - noCategoriesFound - ) { - e.preventDefault(); - onAddNewCategoryHandler(); - setCategorySearchQuery(""); - } - }} + + )} + /> + ( + + Description + +