diff --git a/src/app/categories/columns.tsx b/src/app/categories/columns.tsx new file mode 100644 index 0000000..66d252b --- /dev/null +++ b/src/app/categories/columns.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Category } from '@prisma/client'; +import { CellContext, ColumnDefTemplate } from '@tanstack/table-core'; + +export const columns = ( + actionCell: ColumnDefTemplate>, +) => { + + return [ + { + accessorKey: 'name', + header: 'Name', + }, + { + accessorKey: 'color', + header: 'Color', + cell: ({row}) => { + return ( + + + + ); + }, + }, + { + accessorKey: 'createdAt', + header: 'Created at', + cell: ({row}) => { + const date = row.getValue('createdAt') as Date; + return date.toDateString(); + }, + }, + { + accessorKey: 'updatedAt', + header: 'Updated at', + cell: ({row}) => { + const date = row.getValue('updatedAt') as Date; + return date.toDateString(); + }, + }, + { + id: 'actions', + cell: actionCell, + }, + ] as ColumnDef[]; +}; diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx index 9313db2..d460ade 100644 --- a/src/app/categories/page.tsx +++ b/src/app/categories/page.tsx @@ -1,7 +1,30 @@ +import { getUser } from '@/auth'; +import { prismaClient } from '@/prisma'; +import React from 'react'; +import CategoryPageClientContent from '@/components/categoryPageClientComponents'; +import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate'; +import categoryDelete from '@/lib/actions/categoryDelete'; + export default async function CategoriesPage() { + + const user = await getUser(); + + const categories = await prismaClient.category.findMany({ + where: { + userId: user?.id, + }, + orderBy: [ + { + name: 'asc', + }, + ], + }); + return ( -
- Categories -
+ ); } diff --git a/src/app/entities/page.tsx b/src/app/entities/page.tsx index 216eeeb..8c76f29 100644 --- a/src/app/entities/page.tsx +++ b/src/app/entities/page.tsx @@ -1,7 +1,7 @@ import { prismaClient } from '@/prisma'; import { getUser } from '@/auth'; import React from 'react'; -import EntityPageClientContent from '@/components/EntityPageClientComponents'; +import EntityPageClientContent from '@/components/entityPageClientComponents'; import entityCreateUpdate from '@/lib/actions/entityCreateUpdate'; import entityDelete from '@/lib/actions/entityDelete'; diff --git a/src/components/categoryPageClientComponents.tsx b/src/components/categoryPageClientComponents.tsx new file mode 100644 index 0000000..198508d --- /dev/null +++ b/src/components/categoryPageClientComponents.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { Category } from '@prisma/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 { columns } from '@/app/categories/columns'; +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 { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema'; +import CategoryForm from '@/components/form/categoryForm'; + +export default function CategoryPageClientContent({categories, onSubmit, onDelete, className}: { + categories: Category[], + onSubmit: (data: z.infer) => Promise, + onDelete: (id: number) => Promise, + className: string, +}) { + + const router = useRouter(); + + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const [selectedCategory, setSelectedCategory] = useState(undefined); + + async function handleSubmit(data: z.infer) { + 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) => { + const category = row.original as Category; + + return ( +
+ + +
+ ); + }; + + return ( +
+
+

Entities

+ + {/* Edit dialog */} + + + + + + + {selectedCategory?.id ? 'Update Category' : 'Create Category'} + + + + +
+ + {/* Data Table */} + + + {/* Delete confirmation dialog */} + + + Delete Category? +

Are your sure you want to delete the category {selectedCategory?.name}?

+ + + Cancel + + handleDelete(selectedCategory?.id)}> + Delete + + +
+
+
+ ); +} diff --git a/src/components/EntityPageClientComponents.tsx b/src/components/entityPageClientComponents.tsx similarity index 100% rename from src/components/EntityPageClientComponents.tsx rename to src/components/entityPageClientComponents.tsx diff --git a/src/components/form/categoryForm.tsx b/src/components/form/categoryForm.tsx new file mode 100644 index 0000000..89c4bd2 --- /dev/null +++ b/src/components/form/categoryForm.tsx @@ -0,0 +1,103 @@ +'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 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 } from '@prisma/client'; +import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema'; + +export default function CategoryForm({value, onSubmit, className}: { + value: Category | undefined, + onSubmit: (data: z.infer) => Promise + className?: string +}) { + + const router = useRouter(); + const colorInput = React.createRef(); + + const form = useForm>({ + resolver: zodResolver(categoryFormSchema), + defaultValues: { + id: value?.id ?? undefined, + name: value?.name ?? '', + color: value?.color ?? '#' + Math.floor(Math.random() * 16777215).toString(16), + }, + }); + + const handleSubmit = async (data: z.infer) => { + const response = await onSubmit(data); + toast(sonnerContent(response)); + if (response.redirect) { + router.push(response.redirect); + } + }; + + return ( +
+ + + ( + + + + + + + )} + /> + +
+ + ( + + Color +
colorInput.current?.click()} + className="rounded-md aspect-square w-10 cursor-pointer border border-black items-bottom" + style={{backgroundColor: field.value}}> + + +
+
+ )} + /> + + ( + + Name + + + + + + )} + /> +
+ + + + ); +} diff --git a/src/lib/actions/categoryCreateUpdate.ts b/src/lib/actions/categoryCreateUpdate.ts new file mode 100644 index 0000000..4248bd3 --- /dev/null +++ b/src/lib/actions/categoryCreateUpdate.ts @@ -0,0 +1,65 @@ +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 { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema'; + +export default async function categoryCreateUpdate({ + id, + name, + color, +}: z.infer): Promise { + '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 an category.', + redirect: URL_SIGN_IN, + }; + } + + // create/update category + try { + if (id) { + await prismaClient.category.update({ + where: { + id: id, + }, + data: { + name: name, + color: color, + }, + }, + ); + + // return success + return { + type: 'success', + message: `'${name}' updated`, + }; + } else { + await prismaClient.category.create({ + data: { + userId: user.id, + name: name, + color: color, + }, + }); + + // return success + return { + type: 'success', + message: `'${name}' created`, + }; + } + } catch (e) { + return { + type: 'error', + message: 'Failed creating/updating category', + }; + } +} diff --git a/src/lib/actions/categoryDelete.ts b/src/lib/actions/categoryDelete.ts new file mode 100644 index 0000000..1a7f55b --- /dev/null +++ b/src/lib/actions/categoryDelete.ts @@ -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 categoryDelete(id: number): Promise { + 'use server'; + + // check that id is a number + if (!id || isNaN(id)) { + return { + type: 'error', + message: 'Invalid category ID', + }; + } + + // check that user is logged in + const user = await getUser(); + if (!user) { + return { + type: 'error', + message: 'You must be logged in to delete an category.', + redirect: URL_SIGN_IN, + }; + } + + // check that category is associated with user + const category = await prismaClient.category.findFirst({ + where: { + id: id, + userId: user.id, + }, + }); + if (!category) { + return { + type: 'error', + message: 'Category not found', + }; + } + + // delete category + try { + await prismaClient.category.delete({ + where: { + id: category.id, + userId: user.id, + }, + }, + ); + } catch (e) { + return { + type: 'error', + message: 'Failed deleting category', + }; + } + + // return success + return { + type: 'success', + message: `'${category.name}' deleted`, + }; +} diff --git a/src/lib/form-schemas/categoryFormSchema.ts b/src/lib/form-schemas/categoryFormSchema.ts new file mode 100644 index 0000000..256ad89 --- /dev/null +++ b/src/lib/form-schemas/categoryFormSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const categoryFormSchema = z.object({ + id: z.number().positive().optional(), + name: z.string().min(1).max(32), + color: z.string() + .min(7) + .max(7) + .startsWith('#') + .refine(value => value.split('#')[1].match(/^[0-9a-fA-F]{6}$/)), +});