diff --git a/package-lock.json b/package-lock.json index e99aefb..163fe2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,17 +16,22 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", + "@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-slot": "^1.0.2", "@tanstack/react-table": "^8.13.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", + "date-fns": "^3.3.1", "lucia": "^3.0.1", "lucide-react": "^0.350.0", "next": "14.1.3", "next-themes": "^0.2.1", "oslo": "^1.1.3", "react": "^18", + "react-day-picker": "^8.10.0", "react-dom": "^18", "react-hook-form": "^7.51.0", "sonner": "^1.4.3", @@ -1055,6 +1060,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.7.tgz", + "integrity": "sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "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-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "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-popper": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", @@ -1188,6 +1230,37 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", + "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@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-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "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-select": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", @@ -2251,6 +2324,19 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2323,6 +2409,15 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", + "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4943,6 +5038,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.0.tgz", + "integrity": "sha512-mz+qeyrOM7++1NCb1ARXmkjMkzWVh2GL9YiPbRjKe0zHccvekk4HE+0MPOZOrosn8r8zTHIIeOUXTmXRqmkRmg==", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", diff --git a/package.json b/package.json index 8f98fd3..216bd23 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,22 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", + "@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-slot": "^1.0.2", "@tanstack/react-table": "^8.13.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "cmdk": "^1.0.0", + "date-fns": "^3.3.1", "lucia": "^3.0.1", "lucide-react": "^0.350.0", "next": "14.1.3", "next-themes": "^0.2.1", "oslo": "^1.1.3", "react": "^18", + "react-day-picker": "^8.10.0", "react-dom": "^18", "react-hook-form": "^7.51.0", "sonner": "^1.4.3", diff --git a/src/app/categories/columns.tsx b/src/app/categories/columns.tsx index 66d252b..196bcdd 100644 --- a/src/app/categories/columns.tsx +++ b/src/app/categories/columns.tsx @@ -3,6 +3,7 @@ import { ColumnDef } from '@tanstack/react-table'; import { Category } from '@prisma/client'; import { CellContext, ColumnDefTemplate } from '@tanstack/table-core'; +import { format } from 'date-fns'; export const columns = ( actionCell: ColumnDefTemplate>, @@ -29,16 +30,14 @@ export const columns = ( accessorKey: 'createdAt', header: 'Created at', cell: ({row}) => { - const date = row.getValue('createdAt') as Date; - return date.toDateString(); + return format(row.original.createdAt, 'PPP'); }, }, { accessorKey: 'updatedAt', header: 'Updated at', cell: ({row}) => { - const date = row.getValue('updatedAt') as Date; - return date.toDateString(); + return format(row.original.updatedAt, 'PPP'); }, }, { diff --git a/src/app/entities/columns.tsx b/src/app/entities/columns.tsx index 17ac7d7..a56dc66 100644 --- a/src/app/entities/columns.tsx +++ b/src/app/entities/columns.tsx @@ -3,6 +3,7 @@ import { ColumnDef } from '@tanstack/react-table'; import { Entity } from '@prisma/client'; import { CellContext, ColumnDefTemplate } from '@tanstack/table-core'; +import { format } from 'date-fns'; export const columns = ( actionCell: ColumnDefTemplate>, @@ -21,16 +22,14 @@ export const columns = ( accessorKey: 'createdAt', header: 'Created at', cell: ({row}) => { - const date = row.getValue('createdAt') as Date; - return date.toDateString(); + return format(row.original.createdAt, 'PPP'); }, }, { accessorKey: 'updatedAt', header: 'Updated at', cell: ({row}) => { - const date = row.getValue('updatedAt') as Date; - return date.toDateString(); + return format(row.original.updatedAt, 'PPP'); }, }, { diff --git a/src/app/payments/columns.tsx b/src/app/payments/columns.tsx new file mode 100644 index 0000000..b092e50 --- /dev/null +++ b/src/app/payments/columns.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Category, Entity, Payment } from '@prisma/client'; +import { CellContext, ColumnDefTemplate } from '@tanstack/table-core'; +import { format } from 'date-fns'; + +export const columns = ( + actionCell: ColumnDefTemplate>, + entities: Entity[], + categories: Category[], +) => { + + return [ + { + accessorKey: 'date', + header: 'Date', + cell: ({row}) => { + return format(row.original.date, 'PPP'); + }, + }, + { + accessorKey: 'amount', + header: 'Amount', + cell: ({row}) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + }).format(row.getValue('amount') as number / 100); + }, + }, + { + accessorKey: 'payorId', + header: 'Payor', + cell: ({row}) => { + const entity = entities.find((entity) => entity.id === row.original.payorId); + return entity?.name ?? '-'; + }, + }, + { + accessorKey: 'payeeId', + header: 'Payee', + cell: ({row}) => { + const entity = entities.find((entity) => entity.id === row.original.payeeId); + return entity?.name ?? '-'; + }, + }, + { + accessorKey: 'categoryId', + header: 'Category', + cell: ({row}) => { + const category = categories.find((category) => category.id === row.original.categoryId); + return category?.name ?? '-'; + }, + }, + { + accessorKey: 'note', + header: 'Note', + }, + { + id: 'actions', + cell: actionCell, + }, + ] as ColumnDef[]; +}; diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index 6b3e460..38d123c 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -1,7 +1,63 @@ +import { getUser } from '@/auth'; +import { prismaClient } from '@/prisma'; +import React from 'react'; +import PaymentPageClientContent from '@/components/paymentPageClientComponents'; +import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate'; +import paymentDelete from '@/lib/actions/paymentDelete'; + export default async function PaymentsPage() { + + const user = await getUser(); + + const payments = await prismaClient.payment.findMany({ + where: { + userId: user?.id, + }, + orderBy: [ + { + date: 'desc', + }, + { + id: 'desc', + }, + ], + }); + + const entities = await prismaClient.entity.findMany({ + where: { + userId: user?.id, + }, + orderBy: [ + { + name: 'asc', + }, + { + id: 'asc', + }, + ], + }); + + const categories = await prismaClient.category.findMany({ + where: { + userId: user?.id, + }, + orderBy: [ + { + name: 'asc', + }, + { + id: 'asc', + }, + ], + }); + return ( -
- Payments -
+ ); } diff --git a/src/components/categoryPageClientComponents.tsx b/src/components/categoryPageClientComponents.tsx index 198508d..6de7ec5 100644 --- a/src/components/categoryPageClientComponents.tsx +++ b/src/components/categoryPageClientComponents.tsx @@ -94,7 +94,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet return (
-

Entities

+

Categories

{/* Edit dialog */} diff --git a/src/components/form/paymentForm.tsx b/src/components/form/paymentForm.tsx new file mode 100644 index 0000000..ddf66dc --- /dev/null +++ b/src/components/form/paymentForm.tsx @@ -0,0 +1,361 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ActionResponse } from '@/lib/types/ActionResponse'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { sonnerContent } from '@/components/ui/sonner'; +import { Category, Entity, Payment } from '@prisma/client'; +import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema'; +import CurrencyInput from '@/components/ui/currency-input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { CalendarIcon, Check, ChevronsUpDown } from 'lucide-react'; +import { format } from 'date-fns'; +import { Calendar } from '@/components/ui/calendar'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import { Textarea } from '@/components/ui/textarea'; + +export default function PaymentForm({value, entities, categories, onSubmit, className}: { + value: Payment | undefined, + entities: Entity[], + categories: Category[], + onSubmit: (data: z.infer) => Promise + className?: string +}) { + + const router = useRouter(); + + const [filter, setFilter] = useState(''); + + const [payorOpen, setPayorOpen] = useState(false); + const [payeeOpen, setPayeeOpen] = useState(false); + const [categoryOpen, setCategoryOpen] = useState(false); + + const form = useForm>({ + resolver: zodResolver(paymentFormSchema), + defaultValues: { + id: value?.id ?? undefined, + amount: value?.amount ?? 0, + date: value?.date ?? new Date(), + payorId: value?.payorId ?? undefined, + payeeId: value?.payeeId ?? undefined, + categoryId: value?.categoryId ?? undefined, + note: value?.note ?? '', + }, + }); + + const handleSubmit = async (data: z.infer) => { + const response = await onSubmit(data); + toast(sonnerContent(response)); + if (response.redirect) { + router.push(response.redirect); + } + }; + + const entitiesMapped = entities?.map((entity) => { + return { + label: entity.name, + value: entity.id, + }; + }) ?? []; + + const categoriesMapped = categories?.map((category) => { + return { + label: category.name, + value: category.id, + }; + }) ?? []; + + return ( +
+ + + ( + + + + + + + )} + /> + +
+ + + + ( + + Date + + + + + + + + { + field.onChange(e); + }} + initialFocus + /> + + + + + )} + /> + + ( + + Payor + { + setPayorOpen(open); + setFilter(''); + }}> + + + + + + + setFilter(e.target.value)} + className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" + placeholder="Search..."/> + + {entitiesMapped + .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) + .map((item) => ( +
{ + field.onChange(item.value); + setPayorOpen(false); + }}> + + {item.label} +
+ ))} + +
+
+
+ +
+ )} + /> + + ( + + Payee + { + setPayeeOpen(open); + setFilter(''); + }}> + + + + + + + setFilter(e.target.value)} + className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" + placeholder="Search..."/> + + {entitiesMapped + .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) + .map((item) => ( +
{ + field.onChange(item.value); + setPayeeOpen(false); + }} + > + + {item.label} +
+ ))} + +
+
+
+ +
+ )} + /> + + ( + + Category + { + setCategoryOpen(open); + setFilter(''); + }}> + + + + + + + setFilter(e.target.value)} + className="flex h-10 w-full rounded-md border-b border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" + placeholder="Search..."/> + + {categoriesMapped + .filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase())) + .map((item) => ( +
{ + field.onChange(item.value); + setCategoryOpen(false); + }} + > + + {item.label} +
+ ))} + +
+
+
+ +
+ )} + /> +
+ + ( + + Note + +