N-FIN-7: add payment page (#11)

Resolves #7
This commit is contained in:
Markus Thielker 2024-03-11 00:57:49 +01:00 committed by GitHub
commit 95f36977da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1324 additions and 12 deletions

108
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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<CellContext<Category, unknown>>,
@ -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');
},
},
{

View file

@ -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<CellContext<Entity, unknown>>,
@ -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');
},
},
{

View file

@ -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<CellContext<Payment, unknown>>,
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<Payment>[];
};

View file

@ -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 (
<main className="flex items-center justify-center min-h-screen text-3xl">
Payments
</main>
<PaymentPageClientContent
payments={payments}
entities={entities}
categories={categories}
onSubmit={paymentCreateUpdate}
onDelete={paymentDelete}
className="flex flex-col justify-center space-y-4 p-10"/>
);
}

View file

@ -94,7 +94,7 @@ export default function CategoryPageClientContent({categories, onSubmit, onDelet
return (
<div className={className}>
<div className="flex items-center justify-between w-full">
<p className="text-3xl font-semibold">Entities</p>
<p className="text-3xl font-semibold">Categories</p>
{/* Edit dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>

View file

@ -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<typeof paymentFormSchema>) => Promise<ActionResponse>
className?: string
}) {
const router = useRouter();
const [filter, setFilter] = useState<string>('');
const [payorOpen, setPayorOpen] = useState(false);
const [payeeOpen, setPayeeOpen] = useState(false);
const [categoryOpen, setCategoryOpen] = useState(false);
const form = useForm<z.infer<typeof paymentFormSchema>>({
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<typeof paymentFormSchema>) => {
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 (
<Form {...form}>
<form autoComplete="off" onSubmit={form.handleSubmit(handleSubmit)}>
<FormField
control={form.control}
name="id"
render={({field}) => (
<FormItem>
<FormControl>
<Input autoComplete="false" type="hidden" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className={className}>
<CurrencyInput
form={form}
name="amount"
label="Amount"
placeholder="Enter amount"/>
<FormField
control={form.control}
name="date"
render={({field}) => (
<FormItem>
<FormLabel>Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
className={cn(
'w-full pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(e) => {
field.onChange(e);
}}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="payorId"
render={({field}) => (
<FormItem>
<FormLabel>Payor</FormLabel>
<Popover open={payorOpen} onOpenChange={(open) => {
setPayorOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => 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..."/>
<ScrollArea className="h-64">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayorOpen(false);
}}>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="payeeId"
render={({field}) => (
<FormItem>
<FormLabel>Payee</FormLabel>
<Popover open={payeeOpen} onOpenChange={(open) => {
setPayeeOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? entitiesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => 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..."/>
<ScrollArea className="h-40">
{entitiesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setPayeeOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({field}) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Popover open={categoryOpen} onOpenChange={(open) => {
setCategoryOpen(open);
setFilter('');
}}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
)}
>
{field.value
? categoriesMapped.find(
(item) => item.value === field.value,
)?.label
: 'Select entity'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[225px] p-0">
<input
value={filter}
onChange={(e) => 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..."/>
<ScrollArea className="h-40">
{categoriesMapped
.filter((entity) => entity.label.toLowerCase().includes(filter.toLowerCase()))
.map((item) => (
<div
className="relative flex cursor-pointer hover:bg-white/10 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
key={item.value}
onClick={() => {
field.onChange(item.value);
setCategoryOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
item.value === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
{item.label}
</div>
))}
<ScrollBar orientation="vertical"/>
</ScrollArea>
</PopoverContent>
</Popover>
<FormMessage/>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="note"
render={({field}) => (
<FormItem className="mb-4">
<FormLabel>Note</FormLabel>
<FormControl>
<Textarea placeholder="Note" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
</form>
</Form>
);
}

View file

@ -0,0 +1,171 @@
'use client';
import React, { useState } from 'react';
import { CellContext } from '@tanstack/table-core';
import { Button } from '@/components/ui/button';
import { Edit, Trash } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { DataTable } from '@/components/ui/data-table';
import { z } from 'zod';
import { ActionResponse } from '@/lib/types/ActionResponse';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
} from '@/components/ui/alert-dialog';
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import { Category, Entity, Payment } from '@prisma/client';
import PaymentForm from '@/components/form/paymentForm';
import { columns } from '@/app/payments/columns';
export default function PaymentPageClientContent({
payments,
entities,
categories,
onSubmit,
onDelete,
className,
}: {
payments: Payment[],
entities: Entity[],
categories: Category[],
onSubmit: (data: z.infer<typeof paymentFormSchema>) => Promise<ActionResponse>,
onDelete: (id: number) => Promise<ActionResponse>,
className: string,
}) {
const router = useRouter();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPayment, setSelectedPayment] = useState<Payment | undefined>(undefined);
async function handleSubmit(data: z.infer<typeof paymentFormSchema>) {
const response = await onSubmit(data);
router.refresh();
setIsEditDialogOpen(false);
return response;
}
async function handleDelete(id: number | undefined) {
if (!id) {
return;
}
const response = await onDelete(id);
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
router.refresh();
setIsDeleteDialogOpen(false);
return response;
}
const actionCell = ({row}: CellContext<Payment, unknown>) => {
const payment = row.original as Payment;
return (
<div className="flex items-center space-x-4">
<Button
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedPayment(payment);
setIsEditDialogOpen(true);
}}>
<span className="sr-only">Edit payment</span>
<Edit className="h-4 w-4"/>
</Button>
<Button
variant="ghost"
className="h-8 w-8 p-0"
onClick={() => {
setSelectedPayment(payment);
setIsDeleteDialogOpen(true);
}}
>
<span className="sr-only">Delete payment</span>
<Trash className="h-4 w-4"/>
</Button>
</div>
);
};
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 (
<div className={className}>
<div className="flex items-center justify-between w-full">
<p className="text-3xl font-semibold">Payments</p>
{/* Edit dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
setSelectedPayment(undefined);
setIsEditDialogOpen(true);
}}>
Create Payment
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedPayment?.id ? 'Update Payment' : 'Create Payment'}</DialogTitle>
</DialogHeader>
<PaymentForm
value={selectedPayment}
entities={entities}
categories={categories}
onSubmit={handleSubmit}
className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4"/>
</DialogContent>
</Dialog>
</div>
{/* Data Table */}
<DataTable
className="w-full"
columns={columns(actionCell, entities, categories)}
data={payments}
pagination/>
{/* Delete confirmation dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>Delete Payment?</AlertDialogHeader>
<p>Are your sure you want to delete the payment?</p>
<AlertDialogFooter>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(selectedPayment?.id)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,67 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({variant: 'outline'}),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({variant: 'ghost'}),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({...props}) => <ChevronLeft className="h-4 w-4"/>,
IconRight: ({...props}) => <ChevronRight className="h-4 w-4"/>,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View file

@ -0,0 +1,157 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({className, ...props}, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {
}
const CommandDialog = ({children, ...props}: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({className, ...props}, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50"/>
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({className, ...props}, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({className, ...props}, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({className, ...props}, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({className, ...props}, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -0,0 +1,70 @@
'use client';
import { useReducer } from 'react';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { UseFormReturn } from 'react-hook-form';
type TextInputProps = {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder: string;
};
const moneyFormatter = Intl.NumberFormat('en-US', {
currency: 'EUR',
currencyDisplay: 'symbol',
currencySign: 'standard',
style: 'currency',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export default function CurrencyInput(props: TextInputProps) {
const initialValue = props.form.getValues()[props.name]
? moneyFormatter.format(props.form.getValues()[props.name] / 100)
: '';
const [value, setValue] = useReducer((_: any, next: string) => {
const digits = next.replace(/\D/g, '');
return moneyFormatter.format(Number(digits) / 100);
}, initialValue);
function handleChange(realChangeFn: Function, formattedValue: string) {
const digits = formattedValue.replace(/\D/g, '');
const realValue = Number(digits);
realChangeFn(realValue);
}
return (
<FormField
control={props.form.control}
name={props.name}
render={({field}) => {
field.value = value;
const _change = field.onChange;
return (
<FormItem>
<FormLabel>{props.label}</FormLabel>
<FormControl>
<Input
placeholder={props.placeholder}
type="text"
{...field}
onChange={(ev) => {
setValue(ev.target.value);
handleChange(_change, ev.target.value);
}}
value={value}
/>
</FormControl>
<FormMessage/>
</FormItem>
);
}}
/>
);
}

View file

@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({className, align = 'center', sideOffset = 4, ...props}, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View file

@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({className, children, ...props}, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar/>
<ScrollAreaPrimitive.Corner/>
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({className, orientation = 'vertical', ...props}, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border"/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View file

@ -0,0 +1,25 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({className, ...props}, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View file

@ -0,0 +1,77 @@
import { z } from 'zod';
import { ActionResponse } from '@/lib/types/ActionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants';
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
export default async function paymentCreateUpdate({
id,
amount,
date,
payorId,
payeeId,
categoryId,
note,
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
'use server';
// check that user is logged in
const user = await getUser();
if (!user) {
return {
type: 'error',
message: 'You must be logged in to create/update a payment.',
redirect: URL_SIGN_IN,
};
}
// create/update payment
try {
if (id) {
await prismaClient.payment.update({
where: {
id: id,
},
data: {
amount: amount,
date: date,
payorId: payorId,
payeeId: payeeId,
categoryId: categoryId,
note: note,
},
},
);
// return success
return {
type: 'success',
message: `Payment updated`,
};
} else {
await prismaClient.payment.create({
data: {
userId: user.id,
amount: amount,
date: date,
payorId: payorId,
payeeId: payeeId,
categoryId: categoryId,
note: note,
},
});
// return success
return {
type: 'success',
message: `Payment created`,
};
}
} catch (e) {
return {
type: 'error',
message: 'Failed creating/updating payment',
};
}
}

View file

@ -0,0 +1,62 @@
import { ActionResponse } from '@/lib/types/ActionResponse';
import { prismaClient } from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants';
export default async function paymentDelete(id: number): Promise<ActionResponse> {
'use server';
// check that id is a number
if (!id || isNaN(id)) {
return {
type: 'error',
message: 'Invalid payment ID',
};
}
// check that user is logged in
const user = await getUser();
if (!user) {
return {
type: 'error',
message: 'You must be logged in to delete a payment.',
redirect: URL_SIGN_IN,
};
}
// check that payment is associated with user
const payment = await prismaClient.payment.findFirst({
where: {
id: id,
userId: user.id,
},
});
if (!payment) {
return {
type: 'error',
message: 'Payment not found',
};
}
// delete payment
try {
await prismaClient.payment.delete({
where: {
id: payment.id,
userId: user.id,
},
},
);
} catch (e) {
return {
type: 'error',
message: 'Failed deleting payment',
};
}
// return success
return {
type: 'success',
message: `Payment deleted`,
};
}

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
export const paymentFormSchema = z.object({
id: z.number().positive().optional(),
amount: z.number().positive(),
date: z.date(),
payorId: z.number().positive(),
payeeId: z.number().positive(),
categoryId: z.number().positive().optional(),
note: z.string().max(255).optional(),
});