From 4e1072cf2b01a0dfa2a71691c686f07e914c4e7c Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 18 Sep 2024 08:28:35 +0700 Subject: [PATCH 01/13] add: package createId for aspect management --- apps/frontend/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 37d7123..fcd6ecc 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,6 +16,7 @@ "@mantine/form": "^7.10.2", "@mantine/hooks": "^7.10.2", "@mantine/notifications": "^7.10.2", + "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd01011..e14911f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@mantine/notifications': specifier: ^7.10.2 version: 7.10.2(@mantine/core@7.10.2(@mantine/hooks@7.10.2(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.10.2(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@paralleldrive/cuid2': + specifier: ^2.2.2 + version: 2.2.2 '@radix-ui/react-checkbox': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 1d2723d32eb26b22439b484a943db3e78911e819 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 18 Sep 2024 08:29:41 +0700 Subject: [PATCH 02/13] create: modal for aspect management --- .../modals/AspectDeleteModal.tsx | 97 ++++++++ .../modals/AspectFormModal.tsx | 224 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx create mode 100644 apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx new file mode 100644 index 0000000..c971a90 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx @@ -0,0 +1,97 @@ +import client from "@/honoClient"; +import { Button, Flex, Modal, Text } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi, useSearch } from "@tanstack/react-router"; +import { deleteAspect } from "../queries/aspectQueries"; +import { notifications } from "@mantine/notifications"; +import fetchRPC from "@/utils/fetchRPC"; + +const routeApi = getRouteApi("/_dashboardLayout/aspect/"); + +export default function AspectDeleteModal() { + const queryClient = useQueryClient(); + + const searchParams = useSearch({ from: "/_dashboardLayout/aspect/" }) as { + delete: string; + }; + + const aspectId = searchParams.delete; + const navigate = routeApi.useNavigate(); + + const aspectQuery = useQuery({ + queryKey: ["management-aspect", aspectId], + queryFn: async () => { + if (!aspectId) return null; + return await fetchRPC( + client["management-aspect"][":id"].$get({ + param: { + id: aspectId, + }, + query: {}, + }) + ); + }, + }); + + const mutation = useMutation({ + mutationKey: ["deleteAspectMutation"], + mutationFn: async ({ id }: { id: string }) => { + return await deleteAspect(id); + }, + onError: (error: unknown) => { + if (error instanceof Error) { + notifications.show({ + message: error.message, + color: "red", + }); + } + }, + onSuccess: () => { + notifications.show({ + message: "Aspek berhasil dihapus.", + color: "green", + }); + queryClient.removeQueries({ queryKey: ["management-aspect", aspectId] }); + queryClient.invalidateQueries({ queryKey: ["management-aspect"] }); + navigate({ search: {} }); + }, + }); + + const isModalOpen = Boolean(searchParams.delete && aspectQuery.data); + + return ( + navigate({ search: {} })} + title={`Konfirmasi Hapus`} + > + + Apakah Anda yakin ingin menghapus aspek{" "} + + {aspectQuery.data?.name} + + ? Tindakan ini tidak dapat diubah. + + + {/* Buttons */} + + + + + + ); +} \ No newline at end of file diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx new file mode 100644 index 0000000..cee975f --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx @@ -0,0 +1,224 @@ +import { Button, Flex, Modal, ScrollArea, TextInput, Group, Text } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { createAspect, updateAspect, getAspectByIdQueryOptions } from "../queries/aspectQueries"; +import { TbDeviceFloppy } from "react-icons/tb"; +import { useEffect } from "react"; +import { notifications } from "@mantine/notifications"; +import FormResponseError from "@/errors/FormResponseError"; +import { createId } from "@paralleldrive/cuid2"; + +// Initialize route API +const routeApi = getRouteApi("/_dashboardLayout/aspect/"); + +export default function AspectFormModal() { + const queryClient = useQueryClient(); + const navigate = routeApi.useNavigate(); + const searchParams = routeApi.useSearch(); + const dataId = searchParams.detail || searchParams.edit; + const isModalOpen = Boolean(dataId || searchParams.create); + const formType = searchParams.detail ? "detail" : searchParams.edit ? "edit" : "create"; + + // Fetch aspect data if editing or viewing details + const aspectQuery = useQuery(getAspectByIdQueryOptions(dataId)); + + const modalTitle = `${formType.charAt(0).toUpperCase() + formType.slice(1)} Aspek`; + + const form = useForm({ + initialValues: { + id: "", + name: "", + subAspects: [{ id: "", name: "", questionCount: 0 }] as { id: string; name: string; questionCount: number }[], + }, + }); + + useEffect(() => { + const data = aspectQuery.data; + + if (!data) { + form.reset(); + return; + } + + form.setValues({ + id: data.id, + name: data.name, + subAspects: data.subAspects?.map(subAspect => ({ + id: subAspect.id || "", + name: subAspect.name, + questionCount: subAspect.questionCount || 0, + })) || [], + }); + + form.setErrors({}); + }, [aspectQuery.data]); + + const mutation = useMutation({ + mutationKey: ["aspectMutation"], + mutationFn: async ( + options: + | { action: "edit"; data: Parameters[0] } + | { action: "create"; data: Parameters[0] } + ) => { + return options.action === "edit" + ? await updateAspect(options.data) + : await createAspect(options.data); + }, + onError: (error: unknown) => { + if (error instanceof FormResponseError) { + form.setErrors(error.formErrors); + return; + } + + if (error instanceof Error) { + notifications.show({ + message: error.message, + color: "red", + }); + } + }, + }); + + type CreateAspectPayload = { + name: string; + subAspects?: string; + }; + + type EditAspectPayload = { + id: string; + name: string; + subAspects?: string; + }; + + const handleSubmit = async (values: typeof form.values) => { + try { + let payload: CreateAspectPayload | EditAspectPayload; + + if (formType === "create") { + payload = { + name: values.name, + subAspects: values.subAspects.length > 0 + ? JSON.stringify( + values.subAspects + .filter(subAspect => subAspect.name.trim() !== "") + .map(subAspect => subAspect.name) + ) + : "", + }; + await createAspect(payload); + } else if (formType === "edit") { + payload = { + id: values.id, + name: values.name, + subAspects: values.subAspects.length > 0 + ? JSON.stringify( + values.subAspects + .filter(subAspect => subAspect.name.trim() !== "") + .map(subAspect => ({ + id: subAspect.id || "", + name: subAspect.name, + questionCount: subAspect.questionCount || 0, + })) + ) + : "", + }; + await updateAspect(payload); + } + + queryClient.invalidateQueries({ queryKey: ["management-aspect"] }); + + notifications.show({ + message: `Aspek ${formType === "create" ? "berhasil dibuat" : "berhasil diedit"}`, + }); + + navigate({ search: {} }); + } catch (error) { + console.error("Error during submit:", error); + } + }; + + return ( + navigate({ search: {} })} + title={modalTitle} + scrollAreaComponent={ScrollArea.Autosize} + size="md" + > +
handleSubmit(values))}> + + + {form.values.subAspects.map((field, index) => ( + + { + const newSubAspects = [...form.values.subAspects]; + newSubAspects[index] = { ...newSubAspects[index], name: event.target.value }; + form.setValues({ subAspects: newSubAspects }); + }} + disabled={formType === "detail"} + /> + Jumlah Soal: {field.questionCount} + {formType !== "detail" && ( + + )} + + ))} + + {formType !== "detail" && ( + + )} + + {/* Buttons */} + + + {formType !== "detail" && ( + + )} + + +
+ ); +} \ No newline at end of file From 92f54d727cf69308d5a95474650ea91ac1a9ab3a Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 18 Sep 2024 08:30:27 +0700 Subject: [PATCH 03/13] create: aspect queries for aspect management --- .../aspectManagement/queries/aspectQueries.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts diff --git a/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts b/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts new file mode 100644 index 0000000..19d09d2 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts @@ -0,0 +1,83 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; +import { InferRequestType } from "hono"; + +export const aspectQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["management-aspect", { page, limit, q }], + queryFn: async () => { + const response = await fetchRPC( + client["management-aspect"].$get({ + query: { + limit: String(limit), + page: String(page), + q, + }, + }) + ); + + return response; + }, + }); + +export const getAspectByIdQueryOptions = (aspectId: string | undefined) => + queryOptions({ + queryKey: ["management-aspect", aspectId], + queryFn: () => + fetchRPC( + client["management-aspect"][":id"].$get({ + param: { + id: aspectId!, + }, + query: {}, + }) + ), + enabled: Boolean(aspectId), + }); + +export const createAspect = async ( + json: { name: string; subAspects?: string } +) => { + try { + return await fetchRPC( + client["management-aspect"].$post({ + json, + }) + ); + } catch (error) { + console.error("Error creating aspect:", error); + throw error; + } +}; + +export const updateAspect = async ( + form: { id: string; name: string; subAspects?: string } +) => { + try { + const payload = { + name: form.name, + subAspects: form.subAspects + ? JSON.parse(form.subAspects) + : [], + }; + + return await fetchRPC( + client["management-aspect"][":id"].$patch({ + param: { + id: form.id, + }, + json: payload, + }) + ); + } catch (error) { + console.error("Error updating aspect:", error); + throw error; + } +}; + +export const deleteAspect = async (id: string) => { + return await fetchRPC( + (client["management-aspect"] as { [key: string]: any })[id].$delete() + ); +}; \ No newline at end of file From 497e091db04417ce5ee5051eb1cbd55387c84f7f Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 18 Sep 2024 08:32:09 +0700 Subject: [PATCH 04/13] create: index for aspect management --- .../_dashboardLayout/aspect/index.lazy.tsx | 93 +++++++++++++++++++ .../routes/_dashboardLayout/aspect/index.tsx | 18 ++++ 2 files changed, 111 insertions(+) create mode 100644 apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx diff --git a/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx new file mode 100644 index 0000000..e9b9c57 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx @@ -0,0 +1,93 @@ +import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries"; +import PageTemplate from "@/components/PageTemplate"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import AspectFormModal from "@/modules/aspectManagement/modals/AspectFormModal"; +import ExtractQueryDataType from "@/types/ExtractQueryDataType"; +import { createColumnHelper } from "@tanstack/react-table"; +import { Flex } from "@mantine/core"; +import createActionButtons from "@/utils/createActionButton"; +import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; +import AspectDeleteModal from "@/modules/aspectManagement/modals/AspectDeleteModal"; + +export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({ + component: AspectPage, +}); + +type DataType = ExtractQueryDataType; + +const columnHelper = createColumnHelper(); + +export default function AspectPage() { + return ( + , ]} + columnDefs={[ + // Number of columns + columnHelper.display({ + header: "#", + cell: (props) => props.row.index + 1, + }), + + // Aspect columns + columnHelper.display({ + header: "Nama Aspek", + cell: (props) => props.row.original.name || "Tidak ada Aspek", + }), + + // Sub aspect columns + columnHelper.display({ + header: "Sub Aspek", + cell: (props) => { + const subAspects = props.row.original.subAspects || []; + return subAspects.length > 0 ? ( + + {subAspects.map((subAspect, index) => ( + + {subAspect.name} + {index < subAspects.length - 1 ? ", " : ""} + + ))} + + ) : ( + Tidak ada Sub Aspek + ); + }, + }), + + // Actions columns + columnHelper.display({ + header: "Aksi", + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: true, + action: `?detail=${props.row.original.id}`, + color: "green", + icon: , + }, + { + label: "Edit", + permission: true, + action: `?edit=${props.row.original.id}`, + color: "orange", + icon: , + }, + { + label: "Hapus", + permission: true, + action: `?delete=${props.row.original.id}`, + color: "red", + icon: , + }, + ])} + + ), + }), + ]} + /> + ); +} \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx b/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx new file mode 100644 index 0000000..bad7abb --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx @@ -0,0 +1,18 @@ +import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries"; +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; + +const searchParamSchema = z.object({ + create: z.boolean().default(false).optional(), + edit: z.string().default("").optional(), + delete: z.string().default("").optional(), + detail: z.string().default("").optional(), +}); + +export const Route = createFileRoute("/_dashboardLayout/aspect/")({ + validateSearch: searchParamSchema, + + loader: ({ context: { queryClient } }) => { + queryClient.ensureQueryData(aspectQueryOptions(0, 10)); + }, +}); From 39c1b7462a1d0c32096937e58a9675d2164a2b64 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 18 Sep 2024 10:05:27 +0700 Subject: [PATCH 05/13] update: modal for aspect management --- .../src/modules/aspectManagement/modals/AspectFormModal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx index cee975f..30cdb79 100644 --- a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx @@ -124,7 +124,7 @@ export default function AspectFormModal() { : "", }; await updateAspect(payload); - } + } queryClient.invalidateQueries({ queryKey: ["management-aspect"] }); @@ -167,7 +167,9 @@ export default function AspectFormModal() { }} disabled={formType === "detail"} /> - Jumlah Soal: {field.questionCount} + {formType === "detail" && ( + Jumlah Soal: {field.questionCount} + )} {formType !== "detail" && ( - )} - + > + Hapus + + )} + ))} {formType !== "detail" && ( @@ -199,7 +221,7 @@ export default function AspectFormModal() { Tambah Sub Aspek )} - + {/* Buttons */} - ); - } else if (typeof property === "string") { - return ( - - ); - } else { - return property; - } -}; - -/** - * Pagination component for handling page navigation. - * - * @param props - The properties object. - * @returns The rendered Pagination component. - */ -const CustomPagination = ({ - currentPage, - totalPages, - onChange, -}: { - currentPage: number; - totalPages: number; - onChange: (page: number) => void; -}) => { - const getPaginationItems = () => { - let items = []; - - // Determine start and end pages - let startPage = - currentPage == totalPages && currentPage > 3 ? - Math.max(1, currentPage - 2) : - Math.max(1, currentPage - 1); - let endPage = - currentPage == 1 ? - Math.min(totalPages, currentPage + 2) : - Math.min(totalPages, currentPage + 1); - - // Add ellipsis if needed - if (startPage > 2) { - items.push(); - } - - // Add page numbers - for (let i = startPage; i <= endPage; i++) { - items.push( - - ); - } - - // Add ellipsis after - if (endPage < totalPages - 1) { - items.push(); - } - - // Add last page - if (endPage < totalPages) { - items.push( - - ); - } - if (currentPage > 2) { - items.unshift( - - ); - } - - return items; - }; - - return ( - - - - - -
- {getPaginationItems().map((item) => ( - - {item} - - ))} -
- - - -
-
- ); -}; - -/** - * PageTemplate component for displaying a paginated table with search and filter functionality. - - * @param props - The properties object. - * @returns The rendered PageTemplate component. - */ -export default function PageTemplate< - TQueryKey extends QueryKey, - TQueryFnData extends Record, - TError, - TData extends Record = TQueryFnData, ->(props: Props) { - const [filterOptions, setFilterOptions] = useState({ - page: 0, - limit: 10, - q: "", - }); - - const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500); - - const query = useQuery({ - ...(typeof props.queryOptions === "function" - ? props.queryOptions( - filterOptions.page, - filterOptions.limit, - debouncedSearchQuery - ) - : props.queryOptions), - placeholderData: keepPreviousData, - }); - - const table = useReactTable({ - data: query.data?.data ?? [], - columns: props.columnDefs, - getCoreRowModel: getCoreRowModel(), - defaultColumn: { - cell: (props) => ( - - {props.getValue() as ReactNode} - - ), - }, - }); - - /** - * Handles the change in search query input with debounce. - * - * @param value - The new search query value. - */ - const handleSearchQueryChange = (value: string) => { - setFilterOptions((prev) => ({ - page: 0, - limit: prev.limit, - q: value, - })); - }; - - /** - * Handles the change in page number. - * - * @param page - The new page number. - */ - const handlePageChange = (page: number) => { - setFilterOptions((prev) => ({ - page: page - 1, // Adjust for zero-based index - limit: prev.limit, - q: prev.q, - })); - }; - - return ( -
-

{props.title}

- - {/* Table Functionality */} -
- {/* Search and Create Button */} -
-
- - handleSearchQueryChange(e.target.value)} - placeholder="Pencarian..." - /> -
-
- {createCreateButton(props.createButton)} -
-
- - {/* Table */} - - - {/* Pagination */} - {query.data && ( -
-
- Per Halaman - -
- -
- - Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems} - -
-
- )} -
- - {props.modals?.map((modal, index) => ( - {modal} - ))} -
-
- ); -} From d97d71fe9594b1403400d57be5132996117e8199 Mon Sep 17 00:00:00 2001 From: Abiyasa Putra Prasetya Date: Fri, 4 Oct 2024 08:25:17 +0700 Subject: [PATCH 09/13] add: page template for aspect management --- apps/frontend/src/components/PageTemplate.tsx | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 apps/frontend/src/components/PageTemplate.tsx diff --git a/apps/frontend/src/components/PageTemplate.tsx b/apps/frontend/src/components/PageTemplate.tsx new file mode 100644 index 0000000..b0cf287 --- /dev/null +++ b/apps/frontend/src/components/PageTemplate.tsx @@ -0,0 +1,360 @@ +/* eslint-disable no-mixed-spaces-and-tabs */ +import React, { ReactNode, useState } from "react"; +import { TbPlus, TbSearch } from "react-icons/tb"; +import DashboardTable from "./DashboardTable"; +import { + ColumnDef, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { + QueryKey, + UseQueryOptions, + keepPreviousData, + useQuery, +} from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Button } from "@/shadcn/components/ui/button"; +import { useNavigate } from "@tanstack/react-router"; +import { Card } from "@/shadcn/components/ui/card"; +import { Input } from "@/shadcn/components/ui/input"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@/shadcn/components/ui/pagination"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shadcn/components/ui/select"; +import { HiChevronLeft, HiChevronRight } from "react-icons/hi"; + +type PaginatedResponse> = { + data: Array; + _metadata: { + currentPage: number; + totalPages: number; + perPage: number; + totalItems: number; + }; +}; + +//ref: https://x.com/TkDodo/status/1491451513264574501 +type Props< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +> = { + title: string; + createButton?: string | true | React.ReactNode; + modals?: React.ReactNode[]; + queryOptions: ( + page: number, + limit: number, + q?: string + ) => UseQueryOptions< + PaginatedResponse, + TError, + PaginatedResponse, + TQueryKey + >; + columnDefs: ColumnDef[]; +}; + +/** + * Creates a "Create New" button or returns the provided React node. + * + * @param property - The property that determines the type of button to create. It can be a boolean, string, or React node. + * @returns The create button element. + */ +const createCreateButton = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + property: Props["createButton"] = true +) => { + const navigate = useNavigate(); + + const addQuery = () => { + navigate({ to: `${window.location.pathname}`, search: { create: true } }); + } + + if (property === true) { + return ( + + ); + } else if (typeof property === "string") { + return ( + + ); + } else { + return property; + } +}; + +/** + * Pagination component for handling page navigation. + * + * @param props - The properties object. + * @returns The rendered Pagination component. + */ +const CustomPagination = ({ + currentPage, + totalPages, + onChange, +}: { + currentPage: number; + totalPages: number; + onChange: (page: number) => void; +}) => { + const getPaginationItems = () => { + let items = []; + + // Determine start and end pages + let startPage = + currentPage == totalPages && currentPage > 3 ? + Math.max(1, currentPage - 2) : + Math.max(1, currentPage - 1); + let endPage = + currentPage == 1 ? + Math.min(totalPages, currentPage + 2) : + Math.min(totalPages, currentPage + 1); + + // Add ellipsis if needed + if (startPage > 2) { + items.push(); + } + + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + items.push( + + ); + } + + // Add ellipsis after + if (endPage < totalPages - 1) { + items.push(); + } + + // Add last page + if (endPage < totalPages) { + items.push( + + ); + } + if (currentPage > 2) { + items.unshift( + + ); + } + + return items; + }; + + return ( + + + + + +
+ {getPaginationItems().map((item) => ( + + {item} + + ))} +
+ + + +
+
+ ); +}; + +/** + * PageTemplate component for displaying a paginated table with search and filter functionality. + + * @param props - The properties object. + * @returns The rendered PageTemplate component. + */ +export default function PageTemplate< + TQueryKey extends QueryKey, + TQueryFnData extends Record, + TError, + TData extends Record = TQueryFnData, +>(props: Props) { + const [filterOptions, setFilterOptions] = useState({ + page: 0, + limit: 10, + q: "", + }); + + const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500); + + const query = useQuery({ + ...(typeof props.queryOptions === "function" + ? props.queryOptions( + filterOptions.page, + filterOptions.limit, + debouncedSearchQuery + ) + : props.queryOptions), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: query.data?.data ?? [], + columns: props.columnDefs, + getCoreRowModel: getCoreRowModel(), + defaultColumn: { + cell: (props) => ( + + {props.getValue() as ReactNode} + + ), + }, + }); + + /** + * Handles the change in search query input with debounce. + * + * @param value - The new search query value. + */ + const handleSearchQueryChange = (value: string) => { + setFilterOptions((prev) => ({ + page: 0, + limit: prev.limit, + q: value, + })); + }; + + /** + * Handles the change in page number. + * + * @param page - The new page number. + */ + const handlePageChange = (page: number) => { + setFilterOptions((prev) => ({ + page: page - 1, // Adjust for zero-based index + limit: prev.limit, + q: prev.q, + })); + }; + + return ( +
+

{props.title}

+ + {/* Table Functionality */} +
+ {/* Search and Create Button */} +
+
+ + handleSearchQueryChange(e.target.value)} + placeholder="Pencarian..." + /> +
+
+ {createCreateButton(props.createButton)} +
+
+ + {/* Table */} + + + {/* Pagination */} + {query.data && ( +
+
+ Per Halaman + +
+ +
+ + Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems} + +
+
+ )} +
+ + {props.modals?.map((modal, index) => ( + {modal} + ))} +
+
+ ); +} From b94bea1237dfee59077dc23593c59e561b182804 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Mon, 7 Oct 2024 09:00:07 +0700 Subject: [PATCH 10/13] add: package for create id on aspect management --- apps/frontend/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6fd0053..6034b9f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@emotion/react": "^11.11.4", "@hookform/resolvers": "^3.9.0", + "@paralleldrive/cuid2": "^2.2.2", "@mantine/core": "^7.10.2", "@mantine/dates": "^7.10.2", "@mantine/form": "^7.10.2", From 75d7e93d4c17610ffb3867767c611ea90ed742c8 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Mon, 7 Oct 2024 09:01:05 +0700 Subject: [PATCH 11/13] revision: backend for aspect management --- .../src/routes/managementAspect/route.ts | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts index ebf11fd..2b4d3a7 100644 --- a/apps/backend/src/routes/managementAspect/route.ts +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -95,6 +95,7 @@ const managementAspectRoute = new Hono() q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined ) ) + .orderBy(aspects.name) .offset(page * limit) .limit(limit); @@ -132,7 +133,8 @@ const managementAspectRoute = new Hono() }) .from(aspects) .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) - .where(inArray(aspects.id, aspectIds)); + .where(inArray(aspects.id, aspectIds)) + .orderBy(aspects.name); // Grouping sub aspects by aspect ID const groupedResult = result.reduce((acc, curr) => { @@ -182,7 +184,7 @@ const managementAspectRoute = new Hono() }, }); } - ) + ) // Get aspect by id .get( @@ -287,10 +289,23 @@ const managementAspectRoute = new Hono() if (aspectData.subAspects) { const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; - // Insert new sub aspects into the database without checking for sub aspect duplication - if (subAspectsArray.length) { + // Create a Set to check for duplicates + const uniqueSubAspects = new Set(); + + // Filter out duplicates + const filteredSubAspects = subAspectsArray.filter((subAspect) => { + if (uniqueSubAspects.has(subAspect)) { + return false; // Skip duplicates + } + uniqueSubAspects.add(subAspect); + return true; // Keep unique sub-aspects + }); + + // Check if there are any unique sub aspects to insert + if (filteredSubAspects.length) { + // Insert new sub aspects into the database await db.insert(subAspects).values( - subAspectsArray.map((subAspect) => ({ + filteredSubAspects.map((subAspect) => ({ aspectId, name: subAspect, })) @@ -305,7 +320,7 @@ const managementAspectRoute = new Hono() 201 ); } - ) + ) // Update aspect .patch( @@ -379,10 +394,20 @@ const managementAspectRoute = new Hono() ); } + // Create a Set to check for duplicate sub-aspects + const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name)); + // Update or add new sub aspects for (const subAspect of newSubAspects) { const existingSubAspect = currentSubAspectMap.has(subAspect.id); + // Check for duplicate sub-aspect names + if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) { + throw notFound({ + message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`, + }); + } + if (existingSubAspect) { // Update if sub aspect already exists await db @@ -402,12 +427,14 @@ const managementAspectRoute = new Hono() await db .insert(subAspects) .values({ - id: subAspect.id, aspectId, name: subAspect.name, createdAt: new Date(), }); } + + // Add the name to the Set after processing + uniqueSubAspectNames.add(subAspect.name); } return c.json({ From 0edd2f40ed94c17f89d597b0d16ce1c4d2bb4e30 Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Mon, 7 Oct 2024 09:02:27 +0700 Subject: [PATCH 12/13] add: notification for validation sub aspect name --- .../src/modules/aspectManagement/modals/AspectFormModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx index 7d676fd..1079c67 100644 --- a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx @@ -150,7 +150,7 @@ export default function AspectFormModal() { }); } else { notifications.show({ - message: "Terjadi kesalahan saat menyimpan aspek.", + message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.", color: "red", }); } From 4a679438be5ef3c50bb05dc14fca57fa4f79b50c Mon Sep 17 00:00:00 2001 From: abiyasa05 Date: Wed, 9 Oct 2024 10:58:57 +0700 Subject: [PATCH 13/13] update: revision for endpoint get on management aspect --- .../src/routes/managementAspect/route.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts index 2b4d3a7..0a5699f 100644 --- a/apps/backend/src/routes/managementAspect/route.ts +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -80,9 +80,19 @@ const managementAspectRoute = new Hono() async (c) => { const { includeTrashed, page, limit, q } = c.req.valid("query"); - const totalCountQuery = includeTrashed - ? sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` - : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + const aspectCountQuery = await db + .select({ + count: sql`count(*)`, + }) + .from(aspects) + .where( + and( + includeTrashed ? undefined : isNull(aspects.deletedAt), + q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined + ) + ); + + const totalItems = Number(aspectCountQuery[0]?.count) || 0; const aspectIdsQuery = await db .select({ @@ -129,7 +139,6 @@ const managementAspectRoute = new Hono() FROM ${questions} WHERE ${questions.subAspectId} = ${subAspects.id} )`.as('questionCount'), - fullCount: totalCountQuery, }) .from(aspects) .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) @@ -178,13 +187,13 @@ const managementAspectRoute = new Hono() data: groupedArray, _metadata: { currentPage: page, - totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), - totalItems: Number(result[0]?.fullCount) ?? 0, + totalPages: Math.ceil(totalItems / limit), + totalItems, perPage: limit, }, }); } - ) + ) // Get aspect by id .get(