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/file-processing.tsx b/src/app/dashboard/file/file-processing.tsx new file mode 100644 index 0000000..31cb4a9 --- /dev/null +++ b/src/app/dashboard/file/file-processing.tsx @@ -0,0 +1,304 @@ +'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 { api } from '~/trpc/react.client'; +import { useRouter } from 'next/navigation'; +import { fromZonedTime } from 'date-fns-tz'; + +type Transaction = { + amount: number; + checked: boolean; + date: Date; + description: string; + tags: string; + type: TransactionType; +}; + +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[]>([]); + 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' }); + + 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 + + {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 new file mode 100644 index 0000000..7d4c14a --- /dev/null +++ b/src/app/dashboard/file/page.tsx @@ -0,0 +1,7 @@ +import { api } from '~/trpc/server'; +import { FileProcessing } from '~/app/dashboard/file/file-processing'; + +export default async function FilePage() { + const user = await api.users.get.query(); + return ; +} 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, +}; 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 })), + }, + }, + }, + }, + }, + }); + } + }), });