From d290ffa6133d5b8e393cc98cec4280a3e96321d8 Mon Sep 17 00:00:00 2001 From: Ricard Mallafre Date: Fri, 19 Apr 2024 20:48:27 +0200 Subject: [PATCH 1/5] feat: parse CSV and show mapping form --- components.json | 2 +- package-lock.json | 67 +++++++++++++++ package.json | 2 + src/app/dashboard/file/page.tsx | 128 +++++++++++++++++++++++++++ src/app/dashboard/layout.tsx | 2 +- src/app/dashboard/page.tsx | 2 +- src/components/ui/select.tsx | 148 ++++++++++++++++++++++++++++++++ 7 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 src/app/dashboard/file/page.tsx create mode 100644 src/components/ui/select.tsx diff --git a/components.json b/components.json index 9a4ed1c..4b5624f 100644 --- a/components.json +++ b/components.json @@ -6,7 +6,7 @@ "tailwind": { "config": "tailwind.config.ts", "css": "src/styles/globals.css", - "baseColor": "primary", + "baseColor": "slate", "cssVariables": false, "prefix": "" }, diff --git a/package-lock.json b/package-lock.json index 91b94ea..3226d6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-label": "2.0.2", "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-scroll-area": "1.0.5", + "@radix-ui/react-select": "2.0.0", "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-tabs": "1.0.4", @@ -38,6 +39,7 @@ "class-variance-authority": "0.7.0", "clsx": "2.1.0", "cmdk": "1.0.0", + "csv-parse": "5.5.5", "currency-symbol-map": "5.1.0", "date-fns": "3.6.0", "date-fns-tz": "3.1.3", @@ -1471,6 +1473,49 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", @@ -1680,6 +1725,23 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz", + "integrity": "sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", @@ -2984,6 +3046,11 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "devOptional": true }, + "node_modules/csv-parse": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", + "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==" + }, "node_modules/currency-symbol-map": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/currency-symbol-map/-/currency-symbol-map-5.1.0.tgz", diff --git a/package.json b/package.json index 024f809..dc8eea7 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-label": "2.0.2", "@radix-ui/react-popover": "1.0.7", "@radix-ui/react-scroll-area": "1.0.5", + "@radix-ui/react-select": "2.0.0", "@radix-ui/react-separator": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@radix-ui/react-tabs": "1.0.4", @@ -62,6 +63,7 @@ "class-variance-authority": "0.7.0", "clsx": "2.1.0", "cmdk": "1.0.0", + "csv-parse": "5.5.5", "currency-symbol-map": "5.1.0", "date-fns": "3.6.0", "date-fns-tz": "3.1.3", diff --git a/src/app/dashboard/file/page.tsx b/src/app/dashboard/file/page.tsx new file mode 100644 index 0000000..9dacd8b --- /dev/null +++ b/src/app/dashboard/file/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { BlockTitle, BlockBody } from '~/app/_components/block'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { parse } from 'csv-parse'; +import { cn } from '~/lib/utils.client'; +import { Button } from '~/components/ui/button'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'; + +export default function FilePage() { + const fileInput = useRef(null); + const [isFileParsed, setIsFileParsed] = useState(false); + const [columns, setColumns] = useState([]); + const [records, setRecords] = useState[]>([]); + + return ( + <> + From CSV file + + + { + const file = fileInput.current?.files?.[0]; + if (!file) { + console.error('No file selected'); + return; + } + + const records = await parseCSV(await file.text()); + setRecords(records); + setColumns(getColumns(records)); + setIsFileParsed(true); + }} + /> + {isFileParsed ? ( +
+
+

The date is at:

+ +
+
+

The amount is at:

+ +
+
+

The description is at:

+ +
+ +
+ ) : null} +
+ + ); +} + +function parseCSV(text: string): Promise[]> { + return new Promise((res, rej) => { + parse(text, { delimiter: ';', columns: true }, (err, records) => { + if (err) return rej(err); + res(records as Record[]); + }); + }); +} + +function getColumns(records: Record[]): string[] { + const columns = new Set(); + for (const record of records) { + for (const column of Object.keys(record)) { + columns.add(column); + } + } + + return [...columns]; +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 7cd2781..e723c8e 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -4,7 +4,7 @@ export default async function DashboardLayout({ children }: { children: React.Re return ( - {children} + {children} ); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6a391e3..3900bdc 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -21,7 +21,7 @@ export default function Dashboard({ searchParams }: { searchParams: RecordTags
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..d37b276 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,148 @@ +'use client'; + +import * as React from 'react'; +import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import * as SelectPrimitive from '@radix-ui/react-select'; + +import { cn } from '~/lib/utils.client'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; From 4152285109c9df3db52ca5d58cfb2ca55affe57d Mon Sep 17 00:00:00 2001 From: Ricard Mallafre Date: Sat, 20 Apr 2024 02:14:22 +0200 Subject: [PATCH 2/5] feat: render transactions and edit fields --- src/app/dashboard/file/page.tsx | 208 ++++++++++++++++++++++++++------ 1 file changed, 170 insertions(+), 38 deletions(-) diff --git a/src/app/dashboard/file/page.tsx b/src/app/dashboard/file/page.tsx index 9dacd8b..931c847 100644 --- a/src/app/dashboard/file/page.tsx +++ b/src/app/dashboard/file/page.tsx @@ -1,56 +1,80 @@ 'use client'; +import { format, parse as parseDate } from 'date-fns'; import { useRef, useState } from 'react'; import { BlockTitle, BlockBody } from '~/app/_components/block'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; -import { parse } from 'csv-parse'; +import { parse as parseCSV } from 'csv-parse'; import { cn } from '~/lib/utils.client'; import { Button } from '~/components/ui/button'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table'; +import { TransactionType } from '@prisma/client'; +import { CheckSquareIcon, SquareIcon } from 'lucide-react'; + +type Transaction = { + amount: number; + checked: boolean; + date: Date; + description: string; + tags: string; + type: TransactionType; +}; export default function FilePage() { const fileInput = useRef(null); - const [isFileParsed, setIsFileParsed] = useState(false); + const [stage, setStage] = useState<'select-file' | 'select-columns' | 'edit-rows'>('select-file'); // 'select-file' | 'select-columns const [columns, setColumns] = useState([]); - const [records, setRecords] = useState[]>([]); + const [records, setRecords] = useState[]>([]); + const [transactions, setTransactions] = useState([]); + const [dateColumn, setDateColumn] = useState(); + const [amountColumn, setAmountColumn] = useState(); + const [descriptionColumn, setDescriptionColumn] = useState(); + const [globalChecked, setGlobalChecked] = useState(true); return ( <> From CSV file - - - { - const file = fileInput.current?.files?.[0]; - if (!file) { - console.error('No file selected'); - return; - } + + {stage === 'select-file' ? ( + <> + + { + const file = fileInput.current?.files?.[0]; + if (!file) { + console.error('No file selected'); + return; + } - const records = await parseCSV(await file.text()); - setRecords(records); - setColumns(getColumns(records)); - setIsFileParsed(true); - }} - /> - {isFileParsed ? ( + const records = await getRecords(await file.text()); + setRecords(records); + setColumns(getColumns(records)); + setStage('select-columns'); + }} + /> + + ) : null} + {stage === 'select-columns' ? (

The date is at:

- @@ -67,7 +91,7 @@ export default function FilePage() {

The amount is at:

- @@ -84,7 +108,7 @@ export default function FilePage() {

The description is at:

- @@ -99,19 +123,127 @@ export default function FilePage() {
- +
) : null} + {stage === 'edit-rows' ? ( + <> + + + + { + setTransactions((transactions) => { + const res = [...transactions]; + for (const t of res) { + t.checked = !globalChecked; + } + return res; + }); + setGlobalChecked(!globalChecked); + }} + className="cursor-pointer" + > + {globalChecked ? : } + + Date + Amount + Type + Description + Tags + + + + {transactions.map((transaction, i) => ( + + { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, checked: !t.checked }; + return res; + }); + }} + > + {transaction.checked ? : } + + {format(transaction.date, 'yyyy-MM-dd')} + {transaction.amount} + { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { + ...t, + type: + res[i]?.type === TransactionType.EXPENSE + ? TransactionType.INCOME + : TransactionType.EXPENSE, + }; + return res; + }); + }} + > + {transaction.type} + + + { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, description: event.target.value }; + return res; + }); + }} + /> + + + + + + ))} + +
+ + + ) : null}
); } -function parseCSV(text: string): Promise[]> { +function getRecords(text: string): Promise[]> { return new Promise((res, rej) => { - parse(text, { delimiter: ';', columns: true }, (err, records) => { + parseCSV(text, { delimiter: ';', columns: true }, (err, records) => { if (err) return rej(err); - res(records as Record[]); + res(records as Record[]); }); }); } From 6027f3852843cace5fc558649ef5561333e9b82c Mon Sep 17 00:00:00 2001 From: Ricard Mallafre Date: Sat, 20 Apr 2024 13:35:29 +0200 Subject: [PATCH 3/5] refactor: select all, align right --- src/app/dashboard/file/page.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/dashboard/file/page.tsx b/src/app/dashboard/file/page.tsx index 931c847..2509747 100644 --- a/src/app/dashboard/file/page.tsx +++ b/src/app/dashboard/file/page.tsx @@ -189,7 +189,7 @@ export default function FilePage() { {transaction.checked ? : } {format(transaction.date, 'yyyy-MM-dd')} - {transaction.amount} + {transaction.amount} { @@ -212,6 +212,7 @@ export default function FilePage() { e.target.select()} defaultValue={transaction.description} onChange={(event) => { setTransactions((transactions) => { @@ -225,13 +226,27 @@ export default function FilePage() { /> - + e.target.select()} + defaultValue={transaction.tags} + onChange={(event) => { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, tags: event.target.value }; + return res; + }); + }} + /> ))} - + ) : null}
From 31ea3afa90a41788e22c0c608c31367476d67e3f Mon Sep 17 00:00:00 2001 From: Ricard Mallafre Date: Sat, 20 Apr 2024 13:41:49 +0200 Subject: [PATCH 4/5] fix: format currency --- src/app/dashboard/file/file-processing.tsx | 279 +++++++++++++++++++++ src/app/dashboard/file/page.tsx | 278 +------------------- 2 files changed, 284 insertions(+), 273 deletions(-) create mode 100644 src/app/dashboard/file/file-processing.tsx diff --git a/src/app/dashboard/file/file-processing.tsx b/src/app/dashboard/file/file-processing.tsx new file mode 100644 index 0000000..11e9ca9 --- /dev/null +++ b/src/app/dashboard/file/file-processing.tsx @@ -0,0 +1,279 @@ +'use client'; + +import { format, parse as parseDate } from 'date-fns'; +import { useRef, useState } from 'react'; +import { BlockTitle, BlockBody } from '~/app/_components/block'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { parse as parseCSV } from 'csv-parse'; +import { cn } from '~/lib/utils.client'; +import { Button } from '~/components/ui/button'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table'; +import { TransactionType } from '@prisma/client'; +import { CheckSquareIcon, SquareIcon } from 'lucide-react'; +import type { RouterOutputs } from '~/trpc/shared'; +import currencySymbolMap from 'currency-symbol-map/map'; + +type Transaction = { + amount: number; + checked: boolean; + date: Date; + description: string; + tags: string; + type: TransactionType; +}; + +export function FileProcessing({ user }: { user: RouterOutputs['users']['get'] }) { + const fileInput = useRef(null); + const [stage, setStage] = useState<'select-file' | 'select-columns' | 'edit-rows'>('select-file'); // 'select-file' | 'select-columns + const [columns, setColumns] = useState([]); + const [records, setRecords] = useState[]>([]); + const [transactions, setTransactions] = useState([]); + const [dateColumn, setDateColumn] = useState(); + const [amountColumn, setAmountColumn] = useState(); + const [descriptionColumn, setDescriptionColumn] = useState(); + const [globalChecked, setGlobalChecked] = useState(true); + + const currencyFormatter = new Intl.NumberFormat('es-ES', { style: 'currency', currency: user.currency ?? 'EUR' }); + + return ( + <> + From CSV file + + {stage === 'select-file' ? ( + <> + + { + const file = fileInput.current?.files?.[0]; + if (!file) { + console.error('No file selected'); + return; + } + + const records = await getRecords(await file.text()); + setRecords(records); + setColumns(getColumns(records)); + setStage('select-columns'); + }} + /> + + ) : null} + {stage === 'select-columns' ? ( +
+
+

The date is at:

+ +
+
+

The amount is at:

+ +
+
+

The description is at:

+ +
+ +
+ ) : null} + {stage === 'edit-rows' ? ( + <> + + + + { + setTransactions((transactions) => { + const res = [...transactions]; + for (const t of res) { + t.checked = !globalChecked; + } + return res; + }); + setGlobalChecked(!globalChecked); + }} + className="cursor-pointer" + > + {globalChecked ? : } + + Date + Amount + Type + Description + Tags + + + + {transactions.map((transaction, i) => ( + + { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, checked: !t.checked }; + return res; + }); + }} + > + {transaction.checked ? : } + + {format(transaction.date, 'yyyy-MM-dd')} + + {currencyFormatter.format(transaction.amount)} + + { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { + ...t, + type: + res[i]?.type === TransactionType.EXPENSE + ? TransactionType.INCOME + : TransactionType.EXPENSE, + }; + return res; + }); + }} + > + {transaction.type} + + + e.target.select()} + defaultValue={transaction.description} + onChange={(event) => { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, description: event.target.value }; + return res; + }); + }} + /> + + + e.target.select()} + defaultValue={transaction.tags} + onChange={(event) => { + setTransactions((transactions) => { + const res = [...transactions]; + const t = res[i]; + if (!t) return res; + res[i] = { ...t, tags: event.target.value }; + return res; + }); + }} + /> + + + ))} + +
+ + + ) : null} +
+ + ); +} + +function getRecords(text: string): Promise[]> { + return new Promise((res, rej) => { + parseCSV(text, { delimiter: ';', columns: true }, (err, records) => { + if (err) return rej(err); + res(records as Record[]); + }); + }); +} + +function getColumns(records: Record[]): string[] { + const columns = new Set(); + for (const record of records) { + for (const column of Object.keys(record)) { + columns.add(column); + } + } + + return [...columns]; +} diff --git a/src/app/dashboard/file/page.tsx b/src/app/dashboard/file/page.tsx index 2509747..7d4c14a 100644 --- a/src/app/dashboard/file/page.tsx +++ b/src/app/dashboard/file/page.tsx @@ -1,275 +1,7 @@ -'use client'; +import { api } from '~/trpc/server'; +import { FileProcessing } from '~/app/dashboard/file/file-processing'; -import { format, parse as parseDate } from 'date-fns'; -import { useRef, useState } from 'react'; -import { BlockTitle, BlockBody } from '~/app/_components/block'; -import { Input } from '~/components/ui/input'; -import { Label } from '~/components/ui/label'; -import { parse as parseCSV } from 'csv-parse'; -import { cn } from '~/lib/utils.client'; -import { Button } from '~/components/ui/button'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table'; -import { TransactionType } from '@prisma/client'; -import { CheckSquareIcon, SquareIcon } from 'lucide-react'; - -type Transaction = { - amount: number; - checked: boolean; - date: Date; - description: string; - tags: string; - type: TransactionType; -}; - -export default function FilePage() { - const fileInput = useRef(null); - const [stage, setStage] = useState<'select-file' | 'select-columns' | 'edit-rows'>('select-file'); // 'select-file' | 'select-columns - const [columns, setColumns] = useState([]); - const [records, setRecords] = useState[]>([]); - const [transactions, setTransactions] = useState([]); - const [dateColumn, setDateColumn] = useState(); - const [amountColumn, setAmountColumn] = useState(); - const [descriptionColumn, setDescriptionColumn] = useState(); - const [globalChecked, setGlobalChecked] = useState(true); - - return ( - <> - From CSV file - - {stage === 'select-file' ? ( - <> - - { - const file = fileInput.current?.files?.[0]; - if (!file) { - console.error('No file selected'); - return; - } - - const records = await getRecords(await file.text()); - setRecords(records); - setColumns(getColumns(records)); - setStage('select-columns'); - }} - /> - - ) : null} - {stage === 'select-columns' ? ( -
-
-

The date is at:

- -
-
-

The amount is at:

- -
-
-

The description is at:

- -
- -
- ) : null} - {stage === 'edit-rows' ? ( - <> - - - - { - setTransactions((transactions) => { - const res = [...transactions]; - for (const t of res) { - t.checked = !globalChecked; - } - return res; - }); - setGlobalChecked(!globalChecked); - }} - className="cursor-pointer" - > - {globalChecked ? : } - - Date - Amount - Type - Description - Tags - - - - {transactions.map((transaction, i) => ( - - { - setTransactions((transactions) => { - const res = [...transactions]; - const t = res[i]; - if (!t) return res; - res[i] = { ...t, checked: !t.checked }; - return res; - }); - }} - > - {transaction.checked ? : } - - {format(transaction.date, 'yyyy-MM-dd')} - {transaction.amount} - { - setTransactions((transactions) => { - const res = [...transactions]; - const t = res[i]; - if (!t) return res; - res[i] = { - ...t, - type: - res[i]?.type === TransactionType.EXPENSE - ? TransactionType.INCOME - : TransactionType.EXPENSE, - }; - return res; - }); - }} - > - {transaction.type} - - - e.target.select()} - defaultValue={transaction.description} - onChange={(event) => { - setTransactions((transactions) => { - const res = [...transactions]; - const t = res[i]; - if (!t) return res; - res[i] = { ...t, description: event.target.value }; - return res; - }); - }} - /> - - - e.target.select()} - defaultValue={transaction.tags} - onChange={(event) => { - setTransactions((transactions) => { - const res = [...transactions]; - const t = res[i]; - if (!t) return res; - res[i] = { ...t, tags: event.target.value }; - return res; - }); - }} - /> - - - ))} - -
- - - ) : null} -
- - ); -} - -function getRecords(text: string): Promise[]> { - return new Promise((res, rej) => { - parseCSV(text, { delimiter: ';', columns: true }, (err, records) => { - if (err) return rej(err); - res(records as Record[]); - }); - }); -} - -function getColumns(records: Record[]): string[] { - const columns = new Set(); - for (const record of records) { - for (const column of Object.keys(record)) { - columns.add(column); - } - } - - return [...columns]; +export default async function FilePage() { + const user = await api.users.get.query(); + return ; } From d1081ba06c4400ccf2481b03798ed2cadba29ff6 Mon Sep 17 00:00:00 2001 From: Ricard Mallafre Date: Sun, 21 Apr 2024 17:41:06 +0200 Subject: [PATCH 5/5] feat: submit multiple transactions from csv file --- src/app/dashboard/file/file-processing.tsx | 29 ++++++++++- .../api/routers/transactions/personal.ts | 50 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/file/file-processing.tsx b/src/app/dashboard/file/file-processing.tsx index 11e9ca9..31cb4a9 100644 --- a/src/app/dashboard/file/file-processing.tsx +++ b/src/app/dashboard/file/file-processing.tsx @@ -13,7 +13,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~ import { TransactionType } from '@prisma/client'; import { CheckSquareIcon, SquareIcon } from 'lucide-react'; import type { RouterOutputs } from '~/trpc/shared'; -import currencySymbolMap from 'currency-symbol-map/map'; +import { api } from '~/trpc/react.client'; +import { useRouter } from 'next/navigation'; +import { fromZonedTime } from 'date-fns-tz'; type Transaction = { amount: number; @@ -25,7 +27,10 @@ type Transaction = { }; export function FileProcessing({ user }: { user: RouterOutputs['users']['get'] }) { + const router = useRouter(); + const fileInput = useRef(null); + const [isLoading, setIsLoading] = useState(false); const [stage, setStage] = useState<'select-file' | 'select-columns' | 'edit-rows'>('select-file'); // 'select-file' | 'select-columns const [columns, setColumns] = useState([]); const [records, setRecords] = useState[]>([]); @@ -37,6 +42,24 @@ export function FileProcessing({ user }: { user: RouterOutputs['users']['get'] } const currencyFormatter = new Intl.NumberFormat('es-ES', { style: 'currency', currency: user.currency ?? 'EUR' }); + const bulk = api.transactions.personal.bulk.useMutation({ + onMutate: () => setIsLoading(true), + onSettled: () => setIsLoading(false), + onSuccess: () => router.push('/dashboard'), + }); + + function submit() { + const data = transactions.map((t) => ({ + type: t.type, + amount: t.amount, + description: t.description, + tags: t.tags.split(',').map((tag) => tag.trim()), + date: user.timezone ? fromZonedTime(format(t.date, 'yyyy-MM-dd'), user.timezone) : t.date, + })); + + bulk.mutate({ data }); + } + return ( <> From CSV file @@ -250,7 +273,9 @@ export function FileProcessing({ user }: { user: RouterOutputs['users']['get'] } ))} - + ) : null} diff --git a/src/server/api/routers/transactions/personal.ts b/src/server/api/routers/transactions/personal.ts index 8644b3c..c4261f6 100644 --- a/src/server/api/routers/transactions/personal.ts +++ b/src/server/api/routers/transactions/personal.ts @@ -1,3 +1,4 @@ +import { TransactionType } from '@prisma/client'; import { log } from 'next-axiom'; import { z } from 'zod'; import { toCents } from '~/lib/utils.client'; @@ -236,4 +237,53 @@ export const personalTransactionsRouter = createTRPCRouter({ return null; } }), + + bulk: privateProcedure + .input( + z.object({ + data: z.array( + z.object({ + date: z.date(), + description: z.string(), + amount: z.number(), + type: z.nativeEnum(TransactionType), + tags: z.array(z.string()), + }), + ), + }), + ) + .mutation(async ({ ctx: { db, user }, input: { data } }) => { + for (const { date, amount, description, type, tags } of data) { + await db.tag.createMany({ + data: tags.map((name) => ({ name, createdById: user.id })), + skipDuplicates: true, + }); + + const dbTags = await db.tag.findMany({ + where: { + createdById: user.id, + name: { in: tags }, + }, + }); + + await db.personalTransaction.create({ + data: { + user: { connect: { id: user.id } }, + transaction: { + create: { + amount: toCents(amount), + date, + description, + type, + TransactionsTags: { + createMany: { + data: dbTags.map((tag) => ({ tagId: tag.id })), + }, + }, + }, + }, + }, + }); + } + }), });