diff --git a/README.md b/README.md index efeed21d..f0701323 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ All seamlessly integrated with the Badget to accelerate the development. ## Directory Structure -Dingify is a monorepo managed by [Turborepo](https://turbo.build/repo). The monorepo is split between `apps` and `packages` directories. +Badget is a monorepo managed by [Turborepo](https://turbo.build/repo). The monorepo is split between `apps` and `packages` directories. . ├── apps # Its app workspace which contains diff --git a/apps/www/package.json b/apps/www/package.json index 6b1053b3..67835f96 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -55,6 +55,7 @@ "react": "18.2.0", "react-activity-calendar": "^2.2.8", "react-copy-to-clipboard": "^5.1.0", + "react-csv-importer": "^0.8.1", "react-day-picker": "^8.9.1", "react-dom": "18.2.0", "react-dropzone": "^14.2.3", diff --git a/apps/www/public/testing.csv b/apps/www/public/testing.csv new file mode 100644 index 00000000..c3327296 --- /dev/null +++ b/apps/www/public/testing.csv @@ -0,0 +1,11 @@ +"Date","Name","Amount" +"2016-01-01","Walmart","10.00" +"2016-01-01","Metro Transit","5.00" +"2016-01-02","Costco","20.00" +"2016-01-02","Uber","10.00" +"2016-01-03","Chipotle","30.00" +"2016-01-03","Amtrak","15.00" +"2016-01-04","CVS Pharmacy","40.00" +"2016-01-04","Shell Gas Station","20.00" +"2016-01-05","Nike Store","50.00" +"2016-01-05","City Parking","25.00" diff --git a/apps/www/src/actions/create-populate-transactions.ts b/apps/www/src/actions/create-populate-transactions.ts index 744b6989..c67311f1 100644 --- a/apps/www/src/actions/create-populate-transactions.ts +++ b/apps/www/src/actions/create-populate-transactions.ts @@ -23,46 +23,12 @@ export async function createPopulateTransactions(bankAccountId: string) { }, }); - // Categories to be created - const categories = [ - { name: "Car", icon: "🚗" }, - { name: "Transportation", icon: "🚌" }, - { name: "Clothing", icon: "👗" }, - { name: "Entertainment", icon: "🎬" }, - { name: "Groceries", icon: "🥑" }, - { name: "Other", icon: "🔧" }, - { name: "Rent", icon: "🏠" }, - { name: "Restaurants", icon: "🍽️" }, - { name: "Shops", icon: "🛍️" }, - { name: "Subscriptions", icon: "📺" }, - { name: "Utilities", icon: "💡" }, - ]; - - // Populate categories with userId - await Promise.all( - categories.map(async (category) => { - await prisma.category.upsert({ - where: { - name_userId: { - name: category.name, - userId: user.id, - }, - }, - update: {}, - create: { - name: category.name, - icon: category.icon, - userId: user.id, - }, - }); - }), - ); - - // Fetch category IDs - const createdCategories = await prisma.category.findMany({ + // Fetch existing categories + const categories = await prisma.category.findMany({ where: { userId: user.id }, }); - const categoryMap = createdCategories.reduce((acc, category) => { + + const categoryMap = categories.reduce((acc, category) => { acc[category.name] = category.id; return acc; }, {}); @@ -70,7 +36,7 @@ export async function createPopulateTransactions(bankAccountId: string) { // Sample transactions with various realistic descriptions, dates, and categories const transactions = [ { - amount: 45.99, + amount: 46, date: new Date(), description: "Groceries at Walmart", categoryId: categoryMap["Groceries"], @@ -79,7 +45,7 @@ export async function createPopulateTransactions(bankAccountId: string) { review: true, // Mark as reviewed }, { - amount: 16.75, + amount: 17, date: subDays(new Date(), 1), // Yesterday description: "Lunch at Chipotle", categoryId: categoryMap["Restaurants"], @@ -88,7 +54,7 @@ export async function createPopulateTransactions(bankAccountId: string) { review: true, // Mark as reviewed }, { - amount: 9.99, + amount: 10, date: subDays(new Date(), 2), description: "Netflix Subscription", categoryId: categoryMap["Subscriptions"], @@ -96,7 +62,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 55.2, + amount: 55, date: subDays(new Date(), 3), description: "Gas at Chevron", categoryId: categoryMap["Car"], @@ -104,7 +70,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 120.0, + amount: 120, date: subDays(new Date(), 4), description: "New Shoes from Nike", categoryId: categoryMap["Shops"], @@ -112,7 +78,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 2000.0, + amount: 2000, date: subDays(new Date(), 5), description: "Monthly Salary", categoryId: categoryMap["Income"], @@ -120,7 +86,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 35.5, + amount: 36, date: subDays(new Date(), 6), description: "Dinner at Olive Garden", categoryId: categoryMap["Restaurants"], @@ -128,7 +94,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 15.0, + amount: 15, date: subDays(new Date(), 7), description: "Movie Ticket", categoryId: categoryMap["Entertainment"], @@ -136,7 +102,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 89.99, + amount: 90, date: subDays(new Date(), 8), description: "Electricity Bill", categoryId: categoryMap["Utilities"], @@ -144,7 +110,7 @@ export async function createPopulateTransactions(bankAccountId: string) { currencyIso: currencyIso, }, { - amount: 50.0, + amount: 50, date: subDays(new Date(), 9), description: "Gym Membership", categoryId: categoryMap["Fitness"], diff --git a/apps/www/src/actions/import-transactions.ts b/apps/www/src/actions/import-transactions.ts new file mode 100644 index 00000000..dfcf0f5e --- /dev/null +++ b/apps/www/src/actions/import-transactions.ts @@ -0,0 +1,87 @@ +"use server"; + +import { prisma } from "@/lib/db"; +import { getCurrentUser } from "@/lib/session"; + +interface Transaction { + date: string; + description: string; + amount: number; + category: string; +} + +export async function importTransactions( + bankAccountId: string, + transactions: Transaction[], +) { + const user = await getCurrentUser(); + if (!user) { + throw new Error("User not authenticated"); + } + + const currencyIso = "USD"; // Set the currency ISO code according to your requirements + + // Ensure default categories are created + const defaultCategories = [ + { name: "Car", icon: "🚗" }, + { name: "Transportation", icon: "🚌" }, + { name: "Clothing", icon: "👗" }, + { name: "Entertainment", icon: "🎬" }, + { name: "Groceries", icon: "🥑" }, + { name: "Other", icon: "🔧" }, + { name: "Rent", icon: "🏠" }, + { name: "Restaurants", icon: "🍽️" }, + { name: "Shops", icon: "🛍️" }, + { name: "Subscriptions", icon: "📺" }, + { name: "Utilities", icon: "💡" }, + ]; + + await Promise.all( + defaultCategories.map(async (category) => { + await prisma.category.upsert({ + where: { + name_userId: { + name: category.name, + userId: user.id, + }, + }, + update: {}, + create: { + name: category.name, + icon: category.icon, + userId: user.id, + }, + }); + }), + ); + + // Fetch or create categories + const categories = await prisma.category.findMany({ + where: { userId: user.id }, + }); + + const categoryMap = categories.reduce((acc, category) => { + acc[category.name] = category.id; + return acc; + }, {}); + + // Prepare transactions for insertion + const transactionsData = transactions.map((transaction) => ({ + amount: transaction.amount, + date: new Date(transaction.date), + description: transaction.description, + categoryId: categoryMap[transaction.category] || categoryMap["Other"], + accountId: bankAccountId, + currencyIso: currencyIso, + })); + + try { + await prisma.transaction.createMany({ + data: transactionsData, + }); + return { success: true }; + } catch (error) { + console.error(error); + return { success: false, error: error.message }; + } +} diff --git a/apps/www/src/app/(dashboard)/dashboard/banking/[id]/page.tsx b/apps/www/src/app/(dashboard)/dashboard/banking/[id]/page.tsx index 83ea1487..6aca2b2a 100644 --- a/apps/www/src/app/(dashboard)/dashboard/banking/[id]/page.tsx +++ b/apps/www/src/app/(dashboard)/dashboard/banking/[id]/page.tsx @@ -6,6 +6,7 @@ import BankingDashboardDetails from "@/components/banking/BankingDashboardDetail import { AddTransactionsButton } from "@/components/buttons/AddTransactionsButton"; import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardShell } from "@/components/dashboard/shell"; +import { CSVUploader } from "@/components/import/CsvImporter"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; export default async function BankAccountPage({ @@ -30,7 +31,6 @@ export default async function BankAccountPage({ const bankAccountDetails = await getBankAccountDetails(bankAccountId); const transactions = await getBankAccountTransactions(bankAccountId); console.log(transactions); - console.log(bankAccountDetails); if (!bankAccountDetails) { return ( @@ -48,7 +48,9 @@ export default async function BankAccountPage({ + > + +
{transactions.length === 0 ? ( diff --git a/apps/www/src/app/(dashboard)/dashboard/banking/page.tsx b/apps/www/src/app/(dashboard)/dashboard/banking/page.tsx index 6421cbd1..a97f35fe 100644 --- a/apps/www/src/app/(dashboard)/dashboard/banking/page.tsx +++ b/apps/www/src/app/(dashboard)/dashboard/banking/page.tsx @@ -10,6 +10,7 @@ import { AreaChartBanking } from "@/components/charts/AreaChart"; import { OverallUseageChart } from "@/components/charts/OverallUseageChart"; import { DashboardHeader } from "@/components/dashboard/header"; import { DashboardShell } from "@/components/dashboard/shell"; +import { CSVUploader } from "@/components/import/CsvImporter"; import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; export const metadata = { diff --git a/apps/www/src/components/import/CsvImporter.tsx b/apps/www/src/components/import/CsvImporter.tsx new file mode 100644 index 00000000..387195d0 --- /dev/null +++ b/apps/www/src/components/import/CsvImporter.tsx @@ -0,0 +1,69 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "@dingify/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@dingify/ui/components/dialog"; + +import { cn } from "@/lib/utils"; + +import CSVParser from "./generic-csv-parser"; + +export function CSVUploader({ bankAccountId }: { bankAccountId: string }) { + const handleTestFileImport = () => { + // Create a link element + const link = document.createElement("a"); + // Set the URL to the test file + link.href = "/testing.csv"; + // Set the download attribute to trigger download + link.download = "testing.csv"; + // Append link to the body + document.body.appendChild(link); + // Trigger click event on the link + link.click(); + // Remove link from body + document.body.removeChild(link); + }; + + return ( + + + + + + + Import File + + Export transactions data as a file from bank's website. + + + + + + + + + ); +} + +function UploadForm({ + className, + bankAccountId, +}: { + className?: string; + bankAccountId: string; +}) { + return ( +
+ +
+ ); +} diff --git a/apps/www/src/components/import/generic-csv-parser.tsx b/apps/www/src/components/import/generic-csv-parser.tsx new file mode 100644 index 00000000..1054b365 --- /dev/null +++ b/apps/www/src/components/import/generic-csv-parser.tsx @@ -0,0 +1,63 @@ +"use client"; + +// theme CSS for React CSV Importer +import "react-csv-importer/dist/index.css"; + +import React, { use, useCallback } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { importTransactions } from "@/actions/import-transactions"; // Import your server action +import { Importer, ImporterField } from "react-csv-importer"; +import { toast } from "sonner"; + +export default function CSVParser({ + bankAccountId, +}: { + bankAccountId: string; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + + return params.toString(); + }, + [searchParams], + ); + + const handleData = async (rows) => { + // Import transactions using the server action + const result = await importTransactions(bankAccountId, rows); + if (result.success) { + toast.success("File imported with success!"); + } else { + toast.error(`Failed to import transactions: ${result.error}`); + } + }; + + return ( +
+ { + console.log("starting import of file", file, "with fields", fields); + }} + onComplete={({ file, fields }) => { + console.log("finished import of file", file, "with fields", fields); + }} + onClose={() => { + router.push(`/dashboard/banking/${bankAccountId}`); + }} + > + + + + +
+ ); +} diff --git a/apps/www/src/components/layout/sign-in-modal.tsx b/apps/www/src/components/layout/sign-in-modal.tsx index 4fa8aff8..a3f1add9 100644 --- a/apps/www/src/components/layout/sign-in-modal.tsx +++ b/apps/www/src/components/layout/sign-in-modal.tsx @@ -23,7 +23,7 @@ export const SignInModal = () => {

Sign In

- Join our community and unlock the full potential of Dingify. Sign in + Join our community and unlock the full potential of Badget. Sign in effortlessly with Google to start managing your alerts.

diff --git a/apps/www/src/hooks/use-media-query.ts b/apps/www/src/hooks/use-media-query.ts index c91e543e..7de5ce4e 100644 --- a/apps/www/src/hooks/use-media-query.ts +++ b/apps/www/src/hooks/use-media-query.ts @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useState } from "react"; export default function useMediaQuery() { diff --git a/packages/ui/package.json b/packages/ui/package.json index 609df32d..f19d75ca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -57,7 +57,8 @@ "react-resizable-panels": "^2.0.21", "recharts": "^2.12.7", "sonner": "^1.4.41", - "tailwind-merge": "^2.0.0" + "tailwind-merge": "^2.0.0", + "vaul": "^0.7.7" }, "devDependencies": { "@dingify/eslint-config": "workspace:*", diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx new file mode 100644 index 00000000..f800bdf2 --- /dev/null +++ b/packages/ui/src/components/drawer.tsx @@ -0,0 +1,118 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "../utils"; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = "DrawerContent"; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = "DrawerHeader"; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = "DrawerFooter"; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bad566f3..ac674e05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: react-copy-to-clipboard: specifier: ^5.1.0 version: 5.1.0(react@18.2.0) + react-csv-importer: + specifier: ^0.8.1 + version: 0.8.1(react-dom@18.2.0)(react@18.2.0) react-day-picker: specifier: ^8.9.1 version: 8.10.1(date-fns@2.30.0)(react@18.2.0) @@ -467,6 +470,9 @@ importers: tailwind-merge: specifier: ^2.0.0 version: 2.3.0 + vaul: + specifier: ^0.7.7 + version: 0.7.9(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0)(react@18.2.0) devDependencies: '@dingify/eslint-config': specifier: workspace:* @@ -5124,6 +5130,19 @@ packages: /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + /@use-gesture/core@10.3.1: + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + dev: false + + /@use-gesture/react@10.3.1(react@18.2.0): + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + dependencies: + '@use-gesture/core': 10.3.1 + react: 18.2.0 + dev: false + /@vercel/analytics@1.3.1(next@14.2.3)(react@18.2.0): resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} peerDependencies: @@ -7258,6 +7277,13 @@ packages: dependencies: flat-cache: 4.0.1 + /file-selector@0.5.0: + resolution: {integrity: sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==} + engines: {node: '>= 10'} + dependencies: + tslib: 2.6.3 + dev: false + /file-selector@0.6.0: resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} engines: {node: '>= 12'} @@ -10161,6 +10187,10 @@ packages: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: false + /papaparse@5.4.1: + resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==} + dev: false + /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -10786,6 +10816,19 @@ packages: react: 18.2.0 dev: false + /react-csv-importer@0.8.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GcFQyeNJFFymqqnPi2YEr+UJWrLy0bdKEZ86n7HMcJ1O3ZGU0KQxB5CnYiKLT40QkWP8eAajXXy7o90lHmSj2Q==} + peerDependencies: + react: ^16.8.0 || >=17.0.0 + react-dom: ^16.8.0 || >=17.0.0 + dependencies: + '@use-gesture/react': 10.3.1(react@18.2.0) + papaparse: 5.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-dropzone: 12.1.0(react@18.2.0) + dev: false + /react-day-picker@8.10.1(date-fns@2.30.0)(react@18.2.0): resolution: {integrity: sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==} peerDependencies: @@ -10806,6 +10849,18 @@ packages: scheduler: 0.23.2 dev: false + /react-dropzone@12.1.0(react@18.2.0): + resolution: {integrity: sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.5.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-dropzone@14.2.3(react@18.2.0): resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} engines: {node: '>= 10.13'}