diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index fb8f84d..3a0cd49 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -28,6 +28,13 @@ const sidebarMenus: SidebarMenu[] = [ link: "/assessmentRequest", color: "green", }, + { + label: "Manajemen Aspek", + icon: { tb: "TbClipboardText" }, + allowedPermissions: ["permissions.read"], + link: "/aspect", + color: "blue", + }, ]; export default sidebarMenus; diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts index ebf11fd..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({ @@ -95,6 +105,7 @@ const managementAspectRoute = new Hono() q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined ) ) + .orderBy(aspects.name) .offset(page * limit) .limit(limit); @@ -128,11 +139,11 @@ const managementAspectRoute = new Hono() FROM ${questions} WHERE ${questions.subAspectId} = ${subAspects.id} )`.as('questionCount'), - fullCount: totalCountQuery, }) .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) => { @@ -176,8 +187,8 @@ 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, }, }); @@ -287,10 +298,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 +329,7 @@ const managementAspectRoute = new Hono() 201 ); } - ) + ) // Update aspect .patch( @@ -379,10 +403,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 +436,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({ 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", 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..1079c67 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx @@ -0,0 +1,248 @@ +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 { + // Name field validation + if (values.name.trim() === "") { + form.setErrors({ name: "Nama aspek harus diisi" }); + return; + } + + 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") { + // Add validation for aspect name here + 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); + + if (error instanceof Error && error.message === "Aspect name already exists") { + notifications.show({ + message: "Nama aspek sudah ada. Silakan gunakan nama lain.", + color: "red", + }); + } else { + notifications.show({ + message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.", + color: "red", + }); + } + } + }; + + 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"} + style={{ flex: 1 }} + /> + {formType === "detail" && ( + Jumlah Soal: {field.questionCount} + )} + {formType !== "detail" && ( + + )} + + ))} + + {formType !== "detail" && ( + + )} + + {/* Buttons */} + + + {formType !== "detail" && ( + + )} + + +
+ ); +} \ No newline at end of file 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 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)); + }, +});