From 7db6a10b31008c47cfb7eb585010022147cb788c Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 20 Sep 2024 09:33:15 +0700 Subject: [PATCH 01/13] add: Read, Detail Question Management --- .../modals/QuestionDeleteModal.tsx | 99 +++++++ .../modals/QuestionFormModal.tsx | 261 ++++++++++++++++++ .../queries/questionQueries.ts | 68 +++++ .../_dashboardLayout/questions/index.lazy.tsx | 85 ++++++ .../_dashboardLayout/questions/index.tsx | 18 ++ 5 files changed, 531 insertions(+) create mode 100644 apps/frontend/src/modules/questionsManagement/modals/QuestionDeleteModal.tsx create mode 100644 apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx create mode 100644 apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts create mode 100644 apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/questions/index.tsx diff --git a/apps/frontend/src/modules/questionsManagement/modals/QuestionDeleteModal.tsx b/apps/frontend/src/modules/questionsManagement/modals/QuestionDeleteModal.tsx new file mode 100644 index 0000000..9ce9da2 --- /dev/null +++ b/apps/frontend/src/modules/questionsManagement/modals/QuestionDeleteModal.tsx @@ -0,0 +1,99 @@ +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 { deleteQuestion } from "../queries/questionQueries"; +import { notifications } from "@mantine/notifications"; +import fetchRPC from "@/utils/fetchRPC"; + +const routeApi = getRouteApi("/_dashboardLayout/questions/"); + +export default function QuestionDeleteModal() { + const queryClient = useQueryClient(); + + const searchParams = useSearch({ from: "/_dashboardLayout/questions/" }) as { + delete: string; + }; + + const questionId = searchParams.delete; + const navigate = routeApi.useNavigate(); + + const questionQuery = useQuery({ + queryKey: ["questions", questionId], + queryFn: async () => { + if (!questionId) return null; + return await fetchRPC( + client.questions[":id"].$get({ + param: { + id: questionId, + }, + query: {}, + }) + ); + }, + }); + + const mutation = useMutation({ + mutationKey: ["deleteQuestionMutation"], + mutationFn: async ({ id }: { id: string }) => { + return await deleteQuestion(id); + }, + onError: (error: unknown) => { + if (error instanceof Error) { + notifications.show({ + message: error.message, + color: "red", + }); + } + }, + onSuccess: () => { + notifications.show({ + message: "Question deleted successfully.", + color: "green", + }); + queryClient.removeQueries({ queryKey: ["question", questionId] }); + queryClient.invalidateQueries({ queryKey: ["questions"] }); + navigate({ search: {} }); + }, + }); + + const isModalOpen = Boolean(searchParams.delete && questionQuery.data); + + return ( + navigate({ search: {} })} + title={`Delete confirmation`} + > + + Are you sure you want to delete question{" "} + + "{questionQuery.data?.question}" + + {" "}? This action is irreversible. + + + {/* {errorMessage && {errorMessage}} */} + {/* Buttons */} + + + + + + ); +} diff --git a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx new file mode 100644 index 0000000..486ee96 --- /dev/null +++ b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx @@ -0,0 +1,261 @@ +import { useForm } from "@mantine/form"; +import { + Modal, + Stack, + Button, + Flex, + Avatar, + Center, + ScrollArea, + TextInput, + NumberInput, + Group, + ActionIcon, +} from "@mantine/core"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { TbDeviceFloppy, TbPlus, TbTrash } from "react-icons/tb"; +import { useEffect } from "react"; +import { notifications } from "@mantine/notifications"; +import FormResponseError from "@/errors/FormResponseError"; +import createInputComponents from "@/utils/createInputComponents"; +import stringToColorHex from "@/utils/stringToColorHex"; +import { + createQuestion, + getQuestionByIdQueryOptions, + updateQuestion, +} from "../queries/questionQueries"; + +/** + * Change this + */ +const routeApi = getRouteApi("/_dashboardLayout/questions/"); + +export default function QuestionFormModal() { + /** + * DON'T CHANGE FOLLOWING: + */ + const queryClient = useQueryClient(); + + const navigate = routeApi.useNavigate(); + + const searchParams = routeApi.useSearch(); + + const dataId = searchParams.detail || searchParams.edit; + + const isModalOpen = Boolean(dataId || searchParams.create); + + const detailId = searchParams.detail; + const editId = searchParams.edit; + + const formType = detailId ? "detail" : editId ? "edit" : "create"; + + /** + * CHANGE FOLLOWING: + */ + const questionQuery = useQuery(getQuestionByIdQueryOptions(dataId)); + + const modalTitle = + formType.charAt(0).toUpperCase() + formType.slice(1) + " Question"; + + const form = useForm({ + initialValues: { + id: "", + question: "", + needFile: false, + aspectName: "", + subAspectName: "", + options: [] as { id: string; text: string; score: number }[], + }, + }); + + useEffect(() => { + const data = questionQuery.data; + + if (!data) { + form.reset(); + return; + } + + form.setValues({ + id: data.id, + question: data.question ?? "", + needFile: data.needFile ?? false, + aspectName: data.aspectName ?? "", + subAspectName: data.subAspectName ?? "", + options: data.options ?? [], + }); + + form.setErrors({}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [questionQuery.data]); + + const mutation = useMutation({ + mutationKey: ["questionsMutation"], + mutationFn: async ( + options: + | { action: "edit"; data: Record } + | { action: "create"; data: Record } + ) => { + // return options.action === "edit" + // ? await updateQuestion(options.data) + // : await createQuestion(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", + }); + } + }, + }); + + const handleSubmit = async (values: typeof form.values) => { + if (formType === "detail") return; + + await mutation.mutateAsync({ + action: formType, + data: { + id: values.id, + question: values.question, + needFile: values.needFile, + options: values.options.map((option) => ({ + id: option.id, + text: option.text, + score: option.score, + })), + }, + }); + + queryClient.invalidateQueries({ queryKey: ["questions"] }); + notifications.show({ + message: `The question is ${formType === "create" ? "created" : "edited" + }`, + }); + + navigate({ search: {} }); + }; + + const handleAddOption = () => { + form.insertListItem("options", { id: "", text: "", score: 0 }); + }; + + const handleRemoveOption = (index: number) => { + form.removeListItem("options", index); + }; + + return ( + navigate({ search: {} })} + title={modalTitle} + scrollAreaComponent={ScrollArea.Autosize} + size="md" + > +
handleSubmit(values))}> + + {createInputComponents({ + disableAll: mutation.isPending, + readonlyAll: formType === "detail", + inputs: [ + { + type: "textarea", + label: "Question", + ...form.getInputProps("question"), + }, + formType === "detail" + ? { + type: "text", + label: "Need File", + readOnly: true, + value: form.values.needFile ? "Ya" : "Tidak", + } + : { + type: "checkbox", + label: "Need File", + ...form.getInputProps("needFile"), + }, + { + type: "text", + label: "Aspect Name", + readOnly: true, + ...form.getInputProps("aspectName"), + }, + { + type: "text", + label: "Sub-Aspect Name", + readOnly: true, + ...form.getInputProps("subAspectName"), + }, + ], + })} + + {/* Options */} + + {form.values.options.map((option, index) => ( + + + + {/* Render Trash Icon only if formType is 'create' or 'edit' */} + {formType !== "detail" && ( + handleRemoveOption(index)} + > + + + )} + + ))} + + {/* Render Add Option Button only if formType is 'create' or 'edit' */} + {formType !== "detail" && ( + + )} + + + {/* Buttons */} + + + {formType !== "detail" && ( + + )} + +
+
+ ); +} diff --git a/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts b/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts new file mode 100644 index 0000000..1a0da8a --- /dev/null +++ b/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts @@ -0,0 +1,68 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; +import { InferRequestType } from "hono"; + +export const questionQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["questions", { page, limit, q }], + queryFn: () => + fetchRPC( + client.questions.$get({ + query: { + limit: String(limit), + page: String(page), + q, + }, + }) + ), + }); + +export const getQuestionByIdQueryOptions = (questionId: string | undefined) => + queryOptions({ + queryKey: ["question", questionId], + queryFn: () => + fetchRPC( + client.questions[":id"].$get({ + param: { + id: questionId!, + }, + query: {}, + }) + ), + enabled: Boolean(questionId), + }); + +export const createQuestion = async ( + form: InferRequestType["form"] +) => { + return await fetchRPC( + client.users.$post({ + form, + }) + ); +}; + +export const updateQuestion = async ( + form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { + id: string; + } +) => { + return await fetchRPC( + client.users[":id"].$patch({ + param: { + id: form.id, + }, + form, + }) + ); +}; + +export const deleteQuestion = async (id: string) => { + return await fetchRPC( + client.questions[":id"].$delete({ + param: { id }, + query: {}, + }) + ); +}; diff --git a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx new file mode 100644 index 0000000..1e97992 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx @@ -0,0 +1,85 @@ +import { questionQueryOptions } from "@/modules/questionsManagement/queries/questionQueries"; +import PageTemplate from "@/components/PageTemplate"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import ExtractQueryDataType from "@/types/ExtractQueryDataType"; +import { createColumnHelper } from "@tanstack/react-table"; +import { Badge, Flex } from "@mantine/core"; +import createActionButtons from "@/utils/createActionButton"; +import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; +import QuestionDeleteModal from "@/modules/questionsManagement/modals/QuestionDeleteModal"; +import QuestionFormModal from "@/modules/questionsManagement/modals/QuestionFormModal"; + +export const Route = createLazyFileRoute("/_dashboardLayout/questions/")({ + component: QuestionsPage, +}); + +type DataType = ExtractQueryDataType; + +const columnHelper = createColumnHelper(); + +export default function QuestionsPage() { + return ( + , ]} + columnDefs={[ + columnHelper.display({ + header: "#", + cell: (props) => props.row.index + 1, + }), + + columnHelper.display({ + header: "Nama Aspek", + cell: (props) => props.row.original.aspectName, + }), + + columnHelper.display({ + header: "Nama Sub Aspek", + cell: (props) => props.row.original.subAspectName, + }), + + columnHelper.display({ + header: "Pertanyaan", + cell: (props) => props.row.original.question, + }), + + 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: "Delete", + permission: true, + action: `?delete=${props.row.original.id}`, + color: "red", + icon: , + }, + ])} + + ), + }), + + columnHelper.display({ + header: "Hasil Rata Rata", + // cell: (props) => props.row.original.question, + }), + ]} + /> + ); +} diff --git a/apps/frontend/src/routes/_dashboardLayout/questions/index.tsx b/apps/frontend/src/routes/_dashboardLayout/questions/index.tsx new file mode 100644 index 0000000..a95dd4c --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/questions/index.tsx @@ -0,0 +1,18 @@ +import { questionQueryOptions } from "@/modules/questionsManagement/queries/questionQueries"; +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/questions/")({ + validateSearch: searchParamSchema, + + loader: ({ context: { queryClient } }) => { + queryClient.ensureQueryData(questionQueryOptions(0, 10)); + }, +}); From cd32ac33c7c47266103814a7592f53d3c57b5b0a Mon Sep 17 00:00:00 2001 From: falendikategar Date: Thu, 26 Sep 2024 09:52:33 +0700 Subject: [PATCH 02/13] add: Create, Update, Delete For Question Management --- .../modals/QuestionFormModal.tsx | 253 +++++++++++------- .../queries/questionQueries.ts | 79 ++++-- .../_dashboardLayout/questions/index.lazy.tsx | 4 +- 3 files changed, 226 insertions(+), 110 deletions(-) diff --git a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx index 486ee96..a66dee2 100644 --- a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx +++ b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx @@ -4,13 +4,12 @@ import { Stack, Button, Flex, - Avatar, - Center, + ActionIcon, + Select, ScrollArea, TextInput, NumberInput, Group, - ActionIcon, } from "@mantine/core"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; @@ -24,6 +23,8 @@ import { createQuestion, getQuestionByIdQueryOptions, updateQuestion, + fetchAspects, // Import fetchAspects query + fetchSubAspects, // Import fetchSubAspects query } from "../queries/questionQueries"; /** @@ -31,44 +32,71 @@ import { */ const routeApi = getRouteApi("/_dashboardLayout/questions/"); +interface Option { + questionId: string; + text: string; + score: number; +} + +interface CreateQuestionPayload { + id?: string; // Make id optional + subAspectId: string; // Ensure this matches the correct ID type + question: string; + needFile: boolean; + options: Option[]; // Array of options (text and score) +} + +interface UpdateQuestionPayload { + id: string; // The ID of the question to update + subAspectId: string; // Ensure this matches the correct ID type + question: string; + needFile: boolean; + options?: Option[]; // Optional array of options (text and score) +} + export default function QuestionFormModal() { - /** - * DON'T CHANGE FOLLOWING: - */ const queryClient = useQueryClient(); - const navigate = routeApi.useNavigate(); - const searchParams = routeApi.useSearch(); - const dataId = searchParams.detail || searchParams.edit; - const isModalOpen = Boolean(dataId || searchParams.create); - const detailId = searchParams.detail; const editId = searchParams.edit; - const formType = detailId ? "detail" : editId ? "edit" : "create"; - /** - * CHANGE FOLLOWING: - */ - const questionQuery = useQuery(getQuestionByIdQueryOptions(dataId)); - - const modalTitle = - formType.charAt(0).toUpperCase() + formType.slice(1) + " Question"; - const form = useForm({ initialValues: { id: "", question: "", needFile: false, - aspectName: "", - subAspectName: "", - options: [] as { id: string; text: string; score: number }[], + aspectId: "", + subAspectId: "", + options: [] as { id: string; text: string; score: number; questionId: string }[], }, }); + // Fetch aspects and sub-aspects + const aspectsQuery = useQuery({ + queryKey: ["aspects"], + queryFn: fetchAspects, + }); + + const subAspectsQuery = useQuery({ + queryKey: ["subAspects"], + queryFn: fetchSubAspects, + }); + + // Check for form initialization and aspectId before filtering + const filteredSubAspects = form.values.aspectId + ? subAspectsQuery.data?.filter( + (subAspect) => subAspect.aspectId === form.values.aspectId + ) || [] + : []; + + const questionQuery = useQuery(getQuestionByIdQueryOptions(dataId)); + const modalTitle = + formType.charAt(0).toUpperCase() + formType.slice(1) + " Pertanyaan"; + useEffect(() => { const data = questionQuery.data; @@ -81,32 +109,48 @@ export default function QuestionFormModal() { id: data.id, question: data.question ?? "", needFile: data.needFile ?? false, - aspectName: data.aspectName ?? "", - subAspectName: data.subAspectName ?? "", - options: data.options ?? [], + aspectId: data.aspectId ?? "", + subAspectId: data.subAspectId ?? "", + options: data.options.map((option) => ({ + ...option, + questionId: data.id, + })), }); form.setErrors({}); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [questionQuery.data]); - const mutation = useMutation({ + interface MutationOptions { + action: "edit" | "create"; // Define possible actions + data: CreateQuestionPayload | UpdateQuestionPayload; // Depending on the action, it can be one or the other + } + + interface MutationResponse { + message: string; + } + + const mutation = useMutation({ mutationKey: ["questionsMutation"], - mutationFn: async ( - options: - | { action: "edit"; data: Record } - | { action: "create"; data: Record } - ) => { - // return options.action === "edit" - // ? await updateQuestion(options.data) - // : await createQuestion(options.data); + mutationFn: async (options) => { + if (options.action === "edit") { + return await updateQuestion(options.data as UpdateQuestionPayload); + } else { + return await createQuestion(options.data as CreateQuestionPayload); + } }, - onError: (error: unknown) => { + onSuccess: (data) => { + // You can use data.message here if you want to display success messages + notifications.show({ + message: data.message, + color: "green", + }); + }, + onError: (error) => { if (error instanceof FormResponseError) { form.setErrors(error.formErrors); return; } - + if (error instanceof Error) { notifications.show({ message: error.message, @@ -116,30 +160,48 @@ export default function QuestionFormModal() { }, }); - const handleSubmit = async (values: typeof form.values) => { + const handleSubmit = async (values: CreateQuestionPayload) => { if (formType === "detail") return; - - await mutation.mutateAsync({ - action: formType, - data: { - id: values.id, - question: values.question, - needFile: values.needFile, - options: values.options.map((option) => ({ - id: option.id, - text: option.text, - score: option.score, - })), - }, - }); - - queryClient.invalidateQueries({ queryKey: ["questions"] }); - notifications.show({ - message: `The question is ${formType === "create" ? "created" : "edited" - }`, - }); - - navigate({ search: {} }); + + const payload: CreateQuestionPayload = { + id: values.id, + question: values.question, + needFile: values.needFile, + subAspectId: values.subAspectId, + options: values.options.map((option) => ({ + questionId: values.id || "", // Ensure questionId is included + text: option.text, + score: option.score, + })), + }; + + try { + if (formType === "create") { + await mutation.mutateAsync({ action: "create", data: payload }); + notifications.show({ + message: "Question created successfully!", + color: "green", + }); + } else { + await mutation.mutateAsync({ action: "edit", data: payload }); + notifications.show({ + message: "Question updated successfully!", + color: "green", + }); + } + + queryClient.invalidateQueries({ queryKey: ["questions"] }); + navigate({ search: {} }); + } catch (error) { + if (error instanceof FormResponseError) { + form.setErrors(error.formErrors); + } else if (error instanceof Error) { + notifications.show({ + message: error.message, + color: "red", + }); + } + } }; const handleAddOption = () => { @@ -159,40 +221,47 @@ export default function QuestionFormModal() { size="md" >
handleSubmit(values))}> - {createInputComponents({ disableAll: mutation.isPending, readonlyAll: formType === "detail", inputs: [ + { + type: "select", + label: "Nama Aspek", + data: aspectsQuery.data?.map((aspect) => ({ + value: aspect.id, + label: aspect.name, + })) || [], + disabled: mutation.isPending || formType === "detail", + ...form.getInputProps("aspectId"), + }, + { + type: "select", + label: "Nama Sub Aspek", + data: filteredSubAspects.map((subAspect) => ({ + value: subAspect.id, + label: subAspect.name, + })), + disabled: mutation.isPending || formType === "detail", + ...form.getInputProps("subAspectId"), + }, { type: "textarea", - label: "Question", + label: "Teks Pertanyaan", ...form.getInputProps("question"), }, formType === "detail" ? { - type: "text", - label: "Need File", - readOnly: true, - value: form.values.needFile ? "Ya" : "Tidak", - } + type: "text", + label: "Dibutuhkan Upload File?", + readOnly: true, + value: form.values.needFile ? "Ya" : "Tidak", + } : { - type: "checkbox", - label: "Need File", - ...form.getInputProps("needFile"), - }, - { - type: "text", - label: "Aspect Name", - readOnly: true, - ...form.getInputProps("aspectName"), - }, - { - type: "text", - label: "Sub-Aspect Name", - readOnly: true, - ...form.getInputProps("subAspectName"), - }, + type: "checkbox", + label: "Dibutuhkan Upload File?", + ...form.getInputProps("needFile"), + }, ], })} @@ -201,18 +270,17 @@ export default function QuestionFormModal() { {form.values.options.map((option, index) => ( - {/* Render Trash Icon only if formType is 'create' or 'edit' */} {formType !== "detail" && ( ))} - {/* Render Add Option Button only if formType is 'create' or 'edit' */} {formType !== "detail" && ( )} @@ -242,7 +309,7 @@ export default function QuestionFormModal() { onClick={() => navigate({ search: {} })} disabled={mutation.isPending} > - Close + Tutup {formType !== "detail" && ( )} diff --git a/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts b/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts index 1a0da8a..ea4b71c 100644 --- a/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts +++ b/apps/frontend/src/modules/questionsManagement/queries/questionQueries.ts @@ -3,6 +3,27 @@ import fetchRPC from "@/utils/fetchRPC"; import { queryOptions } from "@tanstack/react-query"; import { InferRequestType } from "hono"; +interface Option { + questionId: string; + text: string; + score: number; +} + +interface CreateQuestionPayload { + subAspectId: string; // Ensure this matches the correct ID type + question: string; + needFile: boolean; + options: Option[]; // Array of options (text and score) +} + +interface UpdateQuestionPayload { + id: string; // The ID of the question to update + subAspectId: string; // Ensure this matches the correct ID type + question: string; + needFile: boolean; + options?: Option[]; // Optional array of options (text and score) +} + export const questionQueryOptions = (page: number, limit: number, q?: string) => queryOptions({ queryKey: ["questions", { page, limit, q }], @@ -33,28 +54,38 @@ export const getQuestionByIdQueryOptions = (questionId: string | undefined) => enabled: Boolean(questionId), }); -export const createQuestion = async ( - form: InferRequestType["form"] -) => { +export const createQuestion = async (form: CreateQuestionPayload) => { return await fetchRPC( - client.users.$post({ - form, + client.questions.$post({ + json: { + question: form.question, + needFile: form.needFile, + subAspectId: form.subAspectId, + options: form.options.map((option) => ({ + text: option.text, + score: option.score, + })), + }, }) ); }; -export const updateQuestion = async ( - form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { - id: string; - } -) => { +export const updateQuestion = async (form: UpdateQuestionPayload) => { return await fetchRPC( - client.users[":id"].$patch({ - param: { - id: form.id, - }, - form, - }) + client.questions[":id"].$patch({ + param: { + id: form.id, + }, + json: { + question: form.question, + needFile: form.needFile, + subAspectId: form.subAspectId, + options: form.options?.map((option: Option) => ({ + text: option.text, + score: option.score, + })), + }, + }) ); }; @@ -66,3 +97,19 @@ export const deleteQuestion = async (id: string) => { }) ); }; + +export const fetchAspects = async () => { + return await fetchRPC( + client.questions.aspects.$get({ + query: {} // Provide an empty query if no parameters are needed + }) // Adjust this based on your API client structure + ); +}; + +export const fetchSubAspects = async () => { + return await fetchRPC( + client.questions.subAspects.$get({ + query: {} // Provide an empty query if no parameters are needed + }) + ); +}; diff --git a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx index 1e97992..bdccd8d 100644 --- a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx @@ -77,7 +77,9 @@ export default function QuestionsPage() { columnHelper.display({ header: "Hasil Rata Rata", - // cell: (props) => props.row.original.question, + cell: (props) => props.row.original.averageScore !== null + ? props.row.original.averageScore + : 0, }), ]} /> From b71025fd4bd946a21a96f0c0f160d89edb3a5313 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Thu, 26 Sep 2024 11:25:02 +0700 Subject: [PATCH 03/13] update: adjustment on alerts and adjustment on the detail form --- .../modals/QuestionFormModal.tsx | 102 +++++++++--------- .../_dashboardLayout/questions/index.lazy.tsx | 2 +- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx index a66dee2..73355c0 100644 --- a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx +++ b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx @@ -5,7 +5,6 @@ import { Button, Flex, ActionIcon, - Select, ScrollArea, TextInput, NumberInput, @@ -18,13 +17,12 @@ import { useEffect } from "react"; import { notifications } from "@mantine/notifications"; import FormResponseError from "@/errors/FormResponseError"; import createInputComponents from "@/utils/createInputComponents"; -import stringToColorHex from "@/utils/stringToColorHex"; import { createQuestion, getQuestionByIdQueryOptions, updateQuestion, - fetchAspects, // Import fetchAspects query - fetchSubAspects, // Import fetchSubAspects query + fetchAspects, + fetchSubAspects, } from "../queries/questionQueries"; /** @@ -39,8 +37,8 @@ interface Option { } interface CreateQuestionPayload { - id?: string; // Make id optional - subAspectId: string; // Ensure this matches the correct ID type + id?: string; + subAspectId: string; question: string; needFile: boolean; options: Option[]; // Array of options (text and score) @@ -48,7 +46,7 @@ interface CreateQuestionPayload { interface UpdateQuestionPayload { id: string; // The ID of the question to update - subAspectId: string; // Ensure this matches the correct ID type + subAspectId: string; question: string; needFile: boolean; options?: Option[]; // Optional array of options (text and score) @@ -120,9 +118,10 @@ export default function QuestionFormModal() { form.setErrors({}); }, [questionQuery.data]); + // Define possible actions, depending on the action, it can be one or the other interface MutationOptions { - action: "edit" | "create"; // Define possible actions - data: CreateQuestionPayload | UpdateQuestionPayload; // Depending on the action, it can be one or the other + action: "edit" | "create"; + data: CreateQuestionPayload | UpdateQuestionPayload; } interface MutationResponse { @@ -138,26 +137,6 @@ export default function QuestionFormModal() { return await createQuestion(options.data as CreateQuestionPayload); } }, - onSuccess: (data) => { - // You can use data.message here if you want to display success messages - notifications.show({ - message: data.message, - color: "green", - }); - }, - onError: (error) => { - if (error instanceof FormResponseError) { - form.setErrors(error.formErrors); - return; - } - - if (error instanceof Error) { - notifications.show({ - message: error.message, - color: "red", - }); - } - }, }); const handleSubmit = async (values: CreateQuestionPayload) => { @@ -179,13 +158,13 @@ export default function QuestionFormModal() { if (formType === "create") { await mutation.mutateAsync({ action: "create", data: payload }); notifications.show({ - message: "Question created successfully!", + message: "Data pertanyaan berhasil dibuat!", color: "green", }); } else { await mutation.mutateAsync({ action: "edit", data: payload }); notifications.show({ - message: "Question updated successfully!", + message: "Data pertanyaan berhasil diperbarui!", color: "green", }); } @@ -225,29 +204,43 @@ export default function QuestionFormModal() { disableAll: mutation.isPending, readonlyAll: formType === "detail", inputs: [ - { - type: "select", - label: "Nama Aspek", - data: aspectsQuery.data?.map((aspect) => ({ - value: aspect.id, - label: aspect.name, - })) || [], - disabled: mutation.isPending || formType === "detail", - ...form.getInputProps("aspectId"), - }, - { - type: "select", - label: "Nama Sub Aspek", - data: filteredSubAspects.map((subAspect) => ({ - value: subAspect.id, - label: subAspect.name, - })), - disabled: mutation.isPending || formType === "detail", - ...form.getInputProps("subAspectId"), - }, + formType === "detail" + ? { + type: "text", + label: "Nama Aspek", + readOnly: true, + value: aspectsQuery.data?.find(aspect => aspect.id === form.values.aspectId)?.name || "", + } + : { + type: "select", + label: "Nama Aspek", + data: aspectsQuery.data?.map((aspect) => ({ + value: aspect.id, + label: aspect.name, + })) || [], + disabled: mutation.isPending, + ...form.getInputProps("aspectId"), + }, + formType === "detail" + ? { + type: "text", + label: "Nama Sub Aspek", + readOnly: true, + value: filteredSubAspects.find(subAspect => subAspect.id === form.values.subAspectId)?.name || "", + } + : { + type: "select", + label: "Nama Sub Aspek", + data: filteredSubAspects.map((subAspect) => ({ + value: subAspect.id, + label: subAspect.name, + })), + disabled: mutation.isPending, + ...form.getInputProps("subAspectId"), + }, { type: "textarea", - label: "Teks Pertanyaan", + label: "Pertanyaan", ...form.getInputProps("question"), }, formType === "detail" @@ -260,6 +253,7 @@ export default function QuestionFormModal() { : { type: "checkbox", label: "Dibutuhkan Upload File?", + checked: form.values.needFile, ...form.getInputProps("needFile"), }, ], @@ -271,13 +265,15 @@ export default function QuestionFormModal() { diff --git a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx index bdccd8d..43d0dc0 100644 --- a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx @@ -3,7 +3,7 @@ import PageTemplate from "@/components/PageTemplate"; import { createLazyFileRoute } from "@tanstack/react-router"; import ExtractQueryDataType from "@/types/ExtractQueryDataType"; import { createColumnHelper } from "@tanstack/react-table"; -import { Badge, Flex } from "@mantine/core"; +import { Flex } from "@mantine/core"; import createActionButtons from "@/utils/createActionButton"; import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; import QuestionDeleteModal from "@/modules/questionsManagement/modals/QuestionDeleteModal"; From dbab6756de1bd3b7096833683674f5f7cdb35a40 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Fri, 27 Sep 2024 11:18:14 +0700 Subject: [PATCH 04/13] update: adjustment on language and table column --- .../_dashboardLayout/questions/index.lazy.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx index 43d0dc0..cf52882 100644 --- a/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/questions/index.lazy.tsx @@ -20,7 +20,7 @@ const columnHelper = createColumnHelper(); export default function QuestionsPage() { return ( , ]} columnDefs={[ @@ -44,6 +44,13 @@ export default function QuestionsPage() { cell: (props) => props.row.original.question, }), + columnHelper.display({ + header: "Hasil Rata Rata", + cell: (props) => props.row.original.averageScore !== null + ? props.row.original.averageScore + : 0, + }), + columnHelper.display({ header: "Aksi", cell: (props) => ( @@ -75,12 +82,6 @@ export default function QuestionsPage() { ), }), - columnHelper.display({ - header: "Hasil Rata Rata", - cell: (props) => props.row.original.averageScore !== null - ? props.row.original.averageScore - : 0, - }), ]} /> ); From 046def0ae497fd27df160e55384da45d8f0497e3 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Tue, 8 Oct 2024 10:01:19 +0700 Subject: [PATCH 05/13] update: adding validation to the modal form --- .../modals/QuestionFormModal.tsx | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx index 73355c0..ed7650f 100644 --- a/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx +++ b/apps/frontend/src/modules/questionsManagement/modals/QuestionFormModal.tsx @@ -25,9 +25,6 @@ import { fetchSubAspects, } from "../queries/questionQueries"; -/** - * Change this - */ const routeApi = getRouteApi("/_dashboardLayout/questions/"); interface Option { @@ -41,15 +38,15 @@ interface CreateQuestionPayload { subAspectId: string; question: string; needFile: boolean; - options: Option[]; // Array of options (text and score) + options: Option[]; } interface UpdateQuestionPayload { - id: string; // The ID of the question to update + id: string; subAspectId: string; question: string; needFile: boolean; - options?: Option[]; // Optional array of options (text and score) + options?: Option[]; } export default function QuestionFormModal() { @@ -71,6 +68,15 @@ export default function QuestionFormModal() { subAspectId: "", options: [] as { id: string; text: string; score: number; questionId: string }[], }, + validate: { + aspectId: (value) => (value ? null : "Nama Aspek harus dipilih."), + subAspectId: (value) => (value ? null : "Nama Sub Aspek harus dipilih."), + question: (value) => (value ? null : "Pertanyaan tidak boleh kosong."), + options: { + text: (value) => (value ? null : "Jawaban tidak boleh kosong."), + score: (value) => (value >= 0 ? null : "Skor harus diisi dengan angka."), + }, + }, }); // Fetch aspects and sub-aspects @@ -148,7 +154,7 @@ export default function QuestionFormModal() { needFile: values.needFile, subAspectId: values.subAspectId, options: values.options.map((option) => ({ - questionId: values.id || "", // Ensure questionId is included + questionId: values.id || "", text: option.text, score: option.score, })), @@ -214,12 +220,14 @@ export default function QuestionFormModal() { : { type: "select", label: "Nama Aspek", + placeholder: "Pilih Aspek", data: aspectsQuery.data?.map((aspect) => ({ value: aspect.id, label: aspect.name, })) || [], disabled: mutation.isPending, ...form.getInputProps("aspectId"), + required: true, }, formType === "detail" ? { @@ -231,16 +239,19 @@ export default function QuestionFormModal() { : { type: "select", label: "Nama Sub Aspek", + placeholder: "Pilih Sub Aspek", data: filteredSubAspects.map((subAspect) => ({ value: subAspect.id, label: subAspect.name, })), disabled: mutation.isPending, ...form.getInputProps("subAspectId"), + required: true, }, { type: "textarea", label: "Pertanyaan", + placeholder: "Tulis Pertanyaan", ...form.getInputProps("question"), }, formType === "detail" @@ -251,10 +262,17 @@ export default function QuestionFormModal() { value: form.values.needFile ? "Ya" : "Tidak", } : { - type: "checkbox", + type: "select", label: "Dibutuhkan Upload File?", - checked: form.values.needFile, - ...form.getInputProps("needFile"), + placeholder: "Pilih opsi", + data: [ + { value: "true", label: "Ya" }, + { value: "false", label: "Tidak" }, + ], + value: form.values.needFile ? "true" : "false", + onChange: (value) => form.setFieldValue("needFile", value === "true"), + disabled: mutation.isPending, + required: true, }, ], })} @@ -274,6 +292,10 @@ export default function QuestionFormModal() { label="Skor" placeholder="Skor" readOnly={formType === "detail"} + min={0} + max={999} + allowDecimal={false} + allowNegative={false} {...form.getInputProps(`options.${index}.score`)} required /> From 630415f91b4545cc8370b3d19cfd7a6208030b75 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Tue, 8 Oct 2024 10:02:46 +0700 Subject: [PATCH 06/13] update: changes to the dashboard table component --- apps/frontend/src/components/DashboardTable.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/frontend/src/components/DashboardTable.tsx b/apps/frontend/src/components/DashboardTable.tsx index 988435c..2708e44 100644 --- a/apps/frontend/src/components/DashboardTable.tsx +++ b/apps/frontend/src/components/DashboardTable.tsx @@ -48,6 +48,8 @@ export default function DashboardTable({ table }: Props) { className="px-6 py-4 whitespace-nowrap text-sm text-black" style={{ maxWidth: `${cell.column.columnDef.maxSize}px`, + whiteSpace: "normal", + wordWrap: "break-word", }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} From a4db081233249d5a7c73ef3580df54fd8e04b852 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 13:35:58 +0700 Subject: [PATCH 07/13] update: changes to backend endpoints --- apps/backend/src/routes/questions/route.ts | 215 +++++++++++++++++++-- 1 file changed, 196 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/routes/questions/route.ts b/apps/backend/src/routes/questions/route.ts index f0bee0f..57117fb 100644 --- a/apps/backend/src/routes/questions/route.ts +++ b/apps/backend/src/routes/questions/route.ts @@ -12,13 +12,22 @@ import checkPermission from "../../middlewares/checkPermission"; import { aspects } from "../../drizzle/schema/aspects"; import { subAspects } from "../../drizzle/schema/subAspects"; import { notFound } from "../../errors/DashboardError"; +import { options } from "../../drizzle/schema/options"; +// Schema for creating and updating options +export const optionFormSchema = z.object({ + text: z.string().min(1).max(255), + score: z.number().min(0), +}); + +// Schema for creating and updating questions export const questionFormSchema = z.object({ subAspectId: z.string().min(1).max(255), question: z.string().min(1).max(255), needFile: z.boolean().default(false), + options: z.array(optionFormSchema).optional(), // Allow options to be included }); - + export const questionUpdateSchema = questionFormSchema.extend({ question: z.string().min(1).max(255).or(z.literal("")), subAspectId: z.string().min(1).max(255).or(z.literal("")), @@ -27,6 +36,59 @@ export const questionUpdateSchema = questionFormSchema.extend({ const questionsRoute = new Hono() .use(authInfo) + /** + * Get All Aspects + */ + .get("/aspects", + checkPermission("questions.readAll"), + async (c) => { + const result = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + }) + .from(aspects); + + return c.json(result); + }) + /** + * Get All Sub Aspects + * + * Query params: + * - aspectId: string (optional) + */ + .get("/subAspects", + checkPermission("questions.readAll"), + requestValidator( + "query", + z.object({ + aspectId: z.string().optional(), + }) + ), + async (c) => { + const { aspectId } = c.req.valid("query"); + + const query = db + .select({ + id: subAspects.id, + name: subAspects.name, + aspectId: subAspects.aspectId, + createdAt: subAspects.createdAt, + updatedAt: subAspects.updatedAt, + }) + .from(subAspects); + + if (aspectId) { + query.where(eq(subAspects.aspectId, aspectId)); + } + + const result = await query; + + return c.json(result); + } + ) /** * Get All Questions (With Metadata) * @@ -70,6 +132,12 @@ const questionsRoute = new Hono() createdAt: questions.createdAt, updatedAt: questions.updatedAt, ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + averageScore: sql`( + SELECT ROUND(AVG(${options.score}), 2) + FROM ${options} + WHERE ${options.questionId} = ${questions.id} + AND ${options.deletedAt} IS NULL -- Include only non-deleted options + )`, fullCount: totalCountQuery, }) .from(questions) @@ -80,14 +148,15 @@ const questionsRoute = new Hono() includeTrashed ? undefined : isNull(questions.deletedAt), q ? or( - ilike(questions.createdAt, q), - ilike(questions.updatedAt, q), - ilike(questions.deletedAt, q), + ilike(questions.question, `%${q}%`), + ilike(aspects.name, `%${q}%`), + ilike(subAspects.name, `%${q}%`), eq(questions.id, q) ) : undefined ) ) + .orderBy(questions.question) .offset(page * limit) .limit(limit); @@ -123,6 +192,11 @@ const questionsRoute = new Hono() async (c) => { const questionId = c.req.param("id"); + if (!questionId) + throw notFound({ + message: "Missing id", + }); + const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; @@ -131,26 +205,53 @@ const questionsRoute = new Hono() id: questions.id, question: questions.question, needFile: questions.needFile, - aspectId: aspects.id, - subAspectId: subAspects.id, + subAspectId: questions.subAspectId, + subAspectName: subAspects.name, + aspectId: subAspects.aspectId, + aspectName: aspects.name, createdAt: questions.createdAt, updatedAt: questions.updatedAt, ...(includeTrashed ? { deletedAt: questions.deletedAt } : {}), + options: { + id: options.id, + text: options.text, + score: options.score, + }, }) .from(questions) .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) + .leftJoin(options, and(eq(questions.id, options.questionId), isNull(options.deletedAt))) // Filter out soft-deleted options .where( and( eq(questions.id, questionId), !includeTrashed ? isNull(questions.deletedAt) : undefined ) - ); + ) + .groupBy(questions.id, questions.question, questions.needFile, subAspects.aspectId, + aspects.name, questions.subAspectId, subAspects.name, questions.createdAt, + questions.updatedAt, options.id, options.text, options.score); if (!queryResult[0]) throw notFound(); + const optionsList = queryResult.reduce((prev, curr) => { + if (!curr.options) return prev; + prev.set(curr.options.id, + { + text: curr.options.text, + score: curr.options.score, + } + ); + return prev; + }, new Map()); + + // Convert Map to Array and sort by the score field in ascending order + const sortedOptions = Array.from(optionsList, ([id, { text, score }]) => ({ id, text, score })) + .sort((a, b) => a.score - b.score); // Sort based on score field in ascending order + const questionData = { ...queryResult[0], + options: sortedOptions, }; return c.json(questionData); @@ -168,20 +269,44 @@ const questionsRoute = new Hono() requestValidator("json", questionFormSchema), async (c) => { const questionData = c.req.valid("json"); - + + // Check if the sub aspect exists + const existingSubAspect = await db + .select() + .from(subAspects) + .where(eq(subAspects.id, questionData.subAspectId)); + + if (existingSubAspect.length === 0) { + return c.json({ message: "Sub aspect not found" }, 404); + } + + // Insert question data into the questions table const question = await db .insert(questions) .values({ - question: questionData.question, - needFile: questionData.needFile, - subAspectId: questionData.subAspectId, + question: questionData.question, + needFile: questionData.needFile, + subAspectId: questionData.subAspectId, }) .returning(); - + + const questionId = question[0].id; + + // Insert options data if provided + if (questionData.options && questionData.options.length > 0) { + const optionsData = questionData.options.map((option) => ({ + questionId: questionId, + text: option.text, + score: option.score, + })); + + await db.insert(options).values(optionsData); + } + return c.json( { - message: "Question created successfully", - data: question, + message: "Question and options created successfully", + data: question[0], }, 201 ); @@ -200,14 +325,16 @@ const questionsRoute = new Hono() async (c) => { const questionId = c.req.param("id"); const questionData = c.req.valid("json"); - + + // Check if the question exists and is not soft deleted const question = await db .select() .from(questions) .where(and(eq(questions.id, questionId), isNull(questions.deletedAt))); - + if (!question[0]) throw notFound(); - + + // Update question data await db .update(questions) .set({ @@ -215,9 +342,59 @@ const questionsRoute = new Hono() updatedAt: new Date(), }) .where(eq(questions.id, questionId)); - + + // Check if options data is provided + if (questionData.options !== undefined) { + // Fetch existing options from the database for this question + const existingOptions = await db + .select() + .from(options) + .where(and(eq(options.questionId, questionId), isNull(options.deletedAt))); + + // Prepare new options data for comparison + const newOptionsData = questionData.options.map((option) => ({ + questionId: questionId, + text: option.text, + score: option.score, + })); + + // Iterate through existing options and perform updates or soft deletes if needed + for (const existingOption of existingOptions) { + const matchingOption = newOptionsData.find( + (newOption) => newOption.text === existingOption.text + ); + + if (!matchingOption) { + // If the existing option is not in the new options data, soft delete it + await db + .update(options) + .set({ deletedAt: new Date() }) + .where(eq(options.id, existingOption.id)); + } else { + // If the option is found, update it if the score has changed + if (existingOption.score !== matchingOption.score) { + await db + .update(options) + .set({ + score: matchingOption.score, + updatedAt: new Date(), + }) + .where(eq(options.id, existingOption.id)); + } + } + } + + // Insert new options that do not exist in the database + const existingOptionTexts = existingOptions.map((opt) => opt.text); + const optionsToInsert = newOptionsData.filter((newOption) => !existingOptionTexts.includes(newOption.text)); + + if (optionsToInsert.length > 0) { + await db.insert(options).values(optionsToInsert); + } + } + return c.json({ - message: "Question updated successfully", + message: "Question and options updated successfully", }); } ) From dfa3cd9f03ac3b0cbdf0662df3519737a4e1ee3d Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 16:09:36 +0700 Subject: [PATCH 08/13] update: changes to backend endpoints in orderBy and search query usage --- .../assessmentRequestManagement/route.ts | 340 +++++++++--------- 1 file changed, 179 insertions(+), 161 deletions(-) diff --git a/apps/backend/src/routes/assessmentRequestManagement/route.ts b/apps/backend/src/routes/assessmentRequestManagement/route.ts index 802c702..8822f31 100644 --- a/apps/backend/src/routes/assessmentRequestManagement/route.ts +++ b/apps/backend/src/routes/assessmentRequestManagement/route.ts @@ -1,173 +1,191 @@ - import { and, eq, ilike, or, sql } from "drizzle-orm"; - import { Hono } from "hono"; - import checkPermission from "../../middlewares/checkPermission"; - import { z } from "zod"; - import { HTTPException } from "hono/http-exception"; - import db from "../../drizzle"; - import { assessments } from "../../drizzle/schema/assessments"; - import { respondents } from "../../drizzle/schema/respondents"; - import { users } from "../../drizzle/schema/users"; - import HonoEnv from "../../types/HonoEnv"; - import requestValidator from "../../utils/requestValidator"; - import authInfo from "../../middlewares/authInfo"; +import { and, eq, ilike, or, sql, desc } from "drizzle-orm"; +import { Hono } from "hono"; +import checkPermission from "../../middlewares/checkPermission"; +import { z } from "zod"; +import { HTTPException } from "hono/http-exception"; +import db from "../../drizzle"; +import { assessments } from "../../drizzle/schema/assessments"; +import { respondents } from "../../drizzle/schema/respondents"; +import { users } from "../../drizzle/schema/users"; +import HonoEnv from "../../types/HonoEnv"; +import requestValidator from "../../utils/requestValidator"; +import authInfo from "../../middlewares/authInfo"; - export const assessmentFormSchema = z.object({ - respondentId: z.string().min(1), - status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]), - reviewedBy: z.string().min(1), - validatedBy: z.string().min(1), - validatedAt: z.string().optional(), - }); +export const assessmentFormSchema = z.object({ + respondentId: z.string().min(1), + status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]), + reviewedBy: z.string().min(1), + validatedBy: z.string().min(1), + validatedAt: z.string().optional(), +}); - export const assessmentUpdateSchema = assessmentFormSchema.extend({ - validatedAt: z.string().optional().or(z.literal("")), - }); +export const assessmentUpdateSchema = assessmentFormSchema.extend({ + validatedAt: z.string().optional().or(z.literal("")), +}); - const assessmentsRequestManagementRoutes = new Hono() - .use(authInfo) - /** - * Get All Assessments (With Metadata) - * - * Query params: - * - withMetadata: boolean - */ - .get( - "/", - checkPermission("assessmentRequestManagement.readAll"), - requestValidator( - "query", - z.object({ - withMetadata: z - .string() - .optional() - .transform((v) => v?.toLowerCase() === "true"), - page: z.coerce.number().int().min(0).default(0), - limit: z.coerce.number().int().min(1).max(1000).default(10), - q: z.string().default(""), +const assessmentsRequestManagementRoutes = new Hono() + .use(authInfo) + /** + * Get All Assessments (With Metadata) + * + * Query params: + * - withMetadata: boolean + */ + .get( + "/", + checkPermission("assessmentRequestManagement.readAll"), + requestValidator( + "query", + z.object({ + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(10), + q: z.string().default(""), + }) + ), + async (c) => { + const { page, limit, q } = c.req.valid("query"); + + // Query untuk menghitung total jumlah item (totalCountQuery) + const assessmentCountQuery = await db + .select({ + count: sql`count(*)`, }) - ), - async (c) => { - const { page, limit, q } = c.req.valid("query"); - - const totalCountQuery = sql`(SELECT count(*) FROM ${assessments})`; - - const result = await db - .select({ - idPermohonan: assessments.id, - namaResponden: users.name, - namaPerusahaan: respondents.companyName, - status: assessments.status, - tanggal: assessments.createdAt, - fullCount: totalCountQuery, - }) - .from(assessments) - .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) - .leftJoin(users, eq(respondents.userId, users.id)) - .where( - q - ? or( - ilike(users.name, `%${q}%`), - ilike(respondents.companyName, `%${q}%`), - eq(assessments.id, q) - ) - : undefined + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where( + q + ? or( + ilike(users.name, `%${q}%`), + ilike(respondents.companyName, `%${q}%`), + sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`, + eq(assessments.id, q) ) - .offset(page * limit) - .limit(limit); + : undefined + ); - return c.json({ - data: result.map((d) => ({ - idPermohonan: d.idPermohonan, - namaResponden: d.namaResponden, - namaPerusahaan: d.namaPerusahaan, - status: d.status, - tanggal: d.tanggal, - })), - _metadata: { - currentPage: page, - totalPages: Math.ceil( - (Number(result[0]?.fullCount) ?? 0) / limit - ), - totalItems: Number(result[0]?.fullCount) ?? 0, - perPage: limit, - }, - }); - } - ) + const totalItems = Number(assessmentCountQuery[0]?.count) || 0; - // Get assessment by id - .get( - "/:id", - checkPermission("assessmentRequestManagement.read"), - async (c) => { - const assessmentId = c.req.param("id"); - - const queryResult = await db - .select({ - // id: assessments.id, - tanggal: assessments.createdAt, - nama: users.name, - posisi: respondents.position, - pengalamanKerja: respondents.workExperience, - email: users.email, - namaPerusahaan: respondents.companyName, - alamat: respondents.address, - nomorTelepon: respondents.phoneNumber, - username: users.username, - status: assessments.status, - }) - .from(assessments) - .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) - .leftJoin(users, eq(respondents.userId, users.id)) - .where(eq(assessments.id, assessmentId)); - - if (!queryResult.length) - throw new HTTPException(404, { - message: "The assessment does not exist", - }); - - const assessmentData = queryResult[0]; - - return c.json(assessmentData); - } - ) - - .patch( - "/:id", - checkPermission("assessmentRequestManagement.update"), - requestValidator( - "json", - z.object({ - status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]), + // Query utama untuk mendapatkan data permohonan assessment + const result = await db + .select({ + idPermohonan: assessments.id, + namaResponden: users.name, + namaPerusahaan: respondents.companyName, + status: assessments.status, + tanggal: assessments.createdAt, }) - ), - async (c) => { - const assessmentId = c.req.param("id"); - const { status } = c.req.valid("json"); - - const assessment = await db - .select() - .from(assessments) - .where(and(eq(assessments.id, assessmentId),)); - - if (!assessment[0]) throw new HTTPException(404, { - message: "Assessment tidak ditemukan.", - }); - - await db - .update(assessments) - .set({ - status, - }) - .where(eq(assessments.id, assessmentId)); - - return c.json({ - message: "Status assessment berhasil diperbarui.", - }); - } - ) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where( + q + ? or( + ilike(users.name, `%${q}%`), + ilike(respondents.companyName, `%${q}%`), + sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`, + eq(assessments.id, q) + ) + : undefined + ) + .orderBy(desc(assessments.createdAt)) + .offset(page * limit) + .limit(limit); + return c.json({ + data: result.map((d) => ({ + idPermohonan: d.idPermohonan, + namaResponden: d.namaResponden, + namaPerusahaan: d.namaPerusahaan, + status: d.status, + tanggal: d.tanggal, + })), + _metadata: { + currentPage: page, + totalPages: Math.ceil(totalItems / limit), + totalItems, + perPage: limit, + }, + }); + } + ) + + // Get assessment by id + .get( + "/:id", + checkPermission("assessmentRequestManagement.read"), + async (c) => { + const assessmentId = c.req.param("id"); + const queryResult = await db + .select({ + tanggal: assessments.createdAt, + nama: users.name, + posisi: respondents.position, + pengalamanKerja: respondents.workExperience, + email: users.email, + namaPerusahaan: respondents.companyName, + alamat: respondents.address, + nomorTelepon: respondents.phoneNumber, + username: users.username, + status: assessments.status, + }) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where(eq(assessments.id, assessmentId)); + + if (!queryResult.length) + throw new HTTPException(404, { + message: "The assessment does not exist", + }); + + const assessmentData = queryResult[0]; + + return c.json(assessmentData); + } + ) + + .patch( + "/:id", + checkPermission("assessmentRequestManagement.update"), + requestValidator( + "json", + z.object({ + status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]), + }) + ), + async (c) => { + const assessmentId = c.req.param("id"); + const { status } = c.req.valid("json"); + + const assessment = await db + .select() + .from(assessments) + .where(and(eq(assessments.id, assessmentId),)); + + if (!assessment[0]) throw new HTTPException(404, { + message: "Assessment tidak ditemukan.", + }); + + await db + .update(assessments) + .set({ + status, + }) + .where(eq(assessments.id, assessmentId)); + + return c.json({ + message: "Status assessment berhasil diperbarui.", + }); + } + ) - export default assessmentsRequestManagementRoutes; + + +export default assessmentsRequestManagementRoutes; From b0ff2f82b38a52ec96102a933175fc8e57c7af1d Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 16:10:50 +0700 Subject: [PATCH 09/13] update: customization of the side bar menu --- apps/backend/src/data/sidebarMenus.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index d501029..3c2fd86 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -35,6 +35,13 @@ const sidebarMenus: SidebarMenu[] = [ link: "/aspect", color: "blue", }, + { + label: "Manajemen Permohonan Asesmen", + icon: { tb: "TbReport" }, + allowedPermissions: ["permissions.read"], + link: "/assessmentRequestManagements", + color: "orange", + }, ]; export default sidebarMenus; From 298e1b7ef1ad163357b4dd18f85dac921808bc77 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 16:11:36 +0700 Subject: [PATCH 10/13] update: adding whitespace rules to the NavbarMenuItem component --- apps/frontend/src/components/NavbarMenuItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/components/NavbarMenuItem.tsx b/apps/frontend/src/components/NavbarMenuItem.tsx index 4b96a4a..d44e45a 100644 --- a/apps/frontend/src/components/NavbarMenuItem.tsx +++ b/apps/frontend/src/components/NavbarMenuItem.tsx @@ -86,7 +86,7 @@ export default function MenuItem({ menu, isActive, onClick }: Props) { {/* Label */} - {menu.label} + {menu.label} {/* Chevron Icon */} {hasChildren && ( From ebd0c3fb61e35ebc2cc4609e7d6f7f9eb9c6f09c Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 16:12:55 +0700 Subject: [PATCH 11/13] add: adding queries and modals to frontend modules --- .../AssessmentRequestManagementFormModal.tsx | 214 ++++++++++++++++++ .../assessmentRequestManagementQueries.ts | 38 ++++ 2 files changed, 252 insertions(+) create mode 100644 apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx create mode 100644 apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts diff --git a/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx b/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx new file mode 100644 index 0000000..1aaa767 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx @@ -0,0 +1,214 @@ +import { Button, Flex, Modal, ScrollArea } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { notifications } from "@mantine/notifications"; +import { fetchAssessmentRequestManagementById, updateAssessmentRequestManagementStatus } from "../queries/assessmentRequestManagementQueries"; +import createInputComponents from "@/utils/createInputComponents"; // Assuming you have this utility +import { useEffect } from "react"; + +// Define the API route for navigation +const routeApi = getRouteApi("/_dashboardLayout/assessmentRequestManagements/"); + +// Define allowed status values +type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai"; + +interface AssessmentRequestManagementFormModalProps { + assessmentId: string | null; + isOpen: boolean; + onClose: () => void; +} + +export default function AssessmentRequestManagementFormModal({ + assessmentId, + isOpen, + onClose, +}: AssessmentRequestManagementFormModalProps) { + const queryClient = useQueryClient(); + const navigate = routeApi.useNavigate(); + + const AssessmentRequestManagementQuery = useQuery({ + queryKey: ["assessmentRequestManagements", assessmentId], + queryFn: async () => { + if (!assessmentId) return null; + return await fetchAssessmentRequestManagementById(assessmentId); + }, + }); + + const form = useForm({ + initialValues: { + tanggal: "", + nama: "", + posisi: "", + pengalamanKerja: "", + email: "", + namaPerusahaan: "", + alamat: "", + nomorTelepon: "", + username: "", + status: "menunggu konfirmasi" as AssessmentStatus, + }, + }); + + // Populate the form once data is available + useEffect(() => { + if (AssessmentRequestManagementQuery.data) { + form.setValues({ + tanggal: formatDate(AssessmentRequestManagementQuery.data.tanggal || "Data Kosong"), + nama: AssessmentRequestManagementQuery.data.nama || "Data Kosong", + posisi: AssessmentRequestManagementQuery.data.posisi || "Data Kosong", + pengalamanKerja: AssessmentRequestManagementQuery.data.pengalamanKerja || "Data Kosong", + email: AssessmentRequestManagementQuery.data.email || "Data Kosong", + namaPerusahaan: AssessmentRequestManagementQuery.data.namaPerusahaan || "Data Kosong", + alamat: AssessmentRequestManagementQuery.data.alamat || "Data Kosong", + nomorTelepon: AssessmentRequestManagementQuery.data.nomorTelepon || "Data Kosong", + username: AssessmentRequestManagementQuery.data.username || "Data Kosong", + status: AssessmentRequestManagementQuery.data.status || "menunggu konfirmasi", + }); + } + }, [AssessmentRequestManagementQuery.data, form]); + + const mutation = useMutation({ + mutationKey: ["updateAssessmentRequestManagementStatusMutation"], + mutationFn: async ({ + id, + status, + }: { + id: string; + status: AssessmentStatus; + }) => { + return await updateAssessmentRequestManagementStatus(id, status); + }, + onError: (error: unknown) => { + if (error instanceof Error) { + notifications.show({ + message: error.message, + color: "red", + }); + } + }, + onSuccess: () => { + notifications.show({ + message: "Status Permohonan Asesmen berhasil diperbarui.", + color: "green", + }); + queryClient.invalidateQueries({ + queryKey: ["assessmentRequestManagements", assessmentId], + }); + onClose(); + }, + }); + + const handleStatusChange = (status: AssessmentStatus) => { + if (assessmentId) { + mutation.mutate({ id: assessmentId, status }); + } + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "Tanggal tidak tersedia"; + + const date = new Date(dateString); + if (isNaN(date.getTime())) return "Tanggal tidak valid"; + + return new Intl.DateTimeFormat("id-ID", { + hour12: true, + minute: "2-digit", + hour: "2-digit", + day: "2-digit", + month: "long", + year: "numeric", + }).format(date); + }; + + const { status } = form.values; + + return ( + + + {createInputComponents({ + disableAll: mutation.isPending, + readonlyAll: true, + inputs: [ + { + type: "text", + label: "Tanggal", + ...form.getInputProps("tanggal"), + }, + { + type: "text", + label: "Nama", + ...form.getInputProps("nama"), + }, + { + type: "text", + label: "Posisi", + ...form.getInputProps("posisi"), + }, + { + type: "text", + label: "Pengalaman Kerja", + ...form.getInputProps("pengalamanKerja"), + }, + { + type: "text", + label: "Email", + ...form.getInputProps("email"), + }, + { + type: "text", + label: "Nama Perusahaan", + ...form.getInputProps("namaPerusahaan"), + }, + { + type: "text", + label: "Alamat", + ...form.getInputProps("alamat"), + }, + { + type: "text", + label: "Nomor Telepon", + ...form.getInputProps("nomorTelepon"), + }, + { + type: "text", + label: "Username", + ...form.getInputProps("username"), + }, + { + type: "text", + label: "Status", + ...form.getInputProps("status"), + }, + ], + })} + + + + {status !== "selesai" && ( + <> + + + + )} + + + + ); +} diff --git a/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts b/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts new file mode 100644 index 0000000..60d6e0f --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts @@ -0,0 +1,38 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; + +// Define allowed status values +type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai"; + +export const assessmentRequestManagementQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["assessmentRequestManagements", { page, limit, q }], + queryFn: () => + fetchRPC( + client.assessmentRequestManagement.$get({ + query: { + limit: String(limit), + page: String(page), + q, + }, + }) + ), + }); + +export async function updateAssessmentRequestManagementStatus(id: string, status: AssessmentStatus) { + return await fetchRPC( + client.assessmentRequestManagement[":id"].$patch({ + param: { id }, + json: { status }, + }) + ); +} + +export async function fetchAssessmentRequestManagementById(id: string) { + return await fetchRPC( + client.assessmentRequestManagement[":id"].$get({ + param: { id }, + }) + ); +} From 977d19d0d26e1407f016daf9dcc4488845d2906f Mon Sep 17 00:00:00 2001 From: falendikategar Date: Wed, 9 Oct 2024 16:14:37 +0700 Subject: [PATCH 12/13] add: adding index and lazy index on dashboard layout --- .../index.lazy.tsx | 137 ++++++++++++++++++ .../assessmentRequestManagements/index.tsx | 18 +++ 2 files changed, 155 insertions(+) create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx new file mode 100644 index 0000000..77f21a8 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx @@ -0,0 +1,137 @@ +import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries"; +import PageTemplate from "@/components/PageTemplate"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import ExtractQueryDataType from "@/types/ExtractQueryDataType"; +import { createColumnHelper } from "@tanstack/react-table"; +import { Badge, Flex } from "@mantine/core"; +import createActionButtons from "@/utils/createActionButton"; +import { TbEye } from "react-icons/tb"; +import AssessmentRequestManagementFormModal from "@/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequestManagements/")({ + component: AssessmentRequestManagementsPage, +}); + +type DataType = ExtractQueryDataType; + +const columnHelper = createColumnHelper(); + +export default function AssessmentRequestManagementsPage() { + const [selectedId, setSelectedId] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const queryClient = useQueryClient(); + + const handleDetailClick = (id: string) => { + setSelectedId(id); + setModalOpen(true); + }; + + // Helper function to format the date + const formatDate = (dateString: string | null) => { + if (!dateString) { + return "Tanggal tidak tersedia"; + } + const date = new Date(dateString); + return new Intl.DateTimeFormat("id-ID", { + hour12: true, + minute: "2-digit", + hour: "2-digit", + day: "2-digit", + month: "long", + year: "numeric", + }).format(date); + }; + + return ( + { + setModalOpen(false); + queryClient.invalidateQueries(); + }} + />, + ]} + createButton={null} + columnDefs={[ + columnHelper.display({ + header: "#", + cell: (props) => props.row.index + 1, + }), + + columnHelper.display({ + header: "Tanggal", + cell: (props) => formatDate(props.row.original.tanggal), + }), + + columnHelper.display({ + header: "Nama Responden", + cell: (props) => props.row.original.namaResponden, + }), + + columnHelper.display({ + header: "Nama Perusahaan", + cell: (props) => props.row.original.namaPerusahaan, + }), + + columnHelper.display({ + header: "Status", + cell: (props) => { + const status = props.row.original.status; + let statusLabel; + let color; + + switch (status) { + case "menunggu konfirmasi": + statusLabel = "Menunggu Konfirmasi"; + color = "yellow"; + break; + case "diterima": + statusLabel = "Diterima"; + color = "green"; + break; + case "ditolak": + statusLabel = "Ditolak"; + color = "red"; + break; + case "selesai": + statusLabel = "Selesai"; + color = "blue"; + break; + default: + statusLabel = "Tidak Diketahui"; + color = "gray"; + break; + } + + return {statusLabel}; + }, + }), + + columnHelper.display({ + header: "Aksi", + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: true, + action: () => handleDetailClick(props.row.original.idPermohonan), + color: "green", + icon: , + }, + ])} + + ), + }), + ]} + /> + ); +} diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx new file mode 100644 index 0000000..426c086 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx @@ -0,0 +1,18 @@ +import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries"; +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/assessmentRequestManagements/")({ + validateSearch: searchParamSchema, + + loader: ({ context: { queryClient } }) => { + queryClient.ensureQueryData(assessmentRequestManagementQueryOptions(0, 10)); + }, +}); From 944719b5a44278fb846e1270a316cd83d15c91f8 Mon Sep 17 00:00:00 2001 From: falendikategar Date: Thu, 10 Oct 2024 09:01:51 +0700 Subject: [PATCH 13/13] update: customization of menu sidebar to match ui/ux order --- apps/backend/src/data/sidebarMenus.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index 3c2fd86..9356717 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -14,6 +14,13 @@ const sidebarMenus: SidebarMenu[] = [ link: "/users", color: "red", }, + { + label: "Manajemen Aspek", + icon: { tb: "TbClipboardText" }, + allowedPermissions: ["permissions.read"], + link: "/aspect", + color: "blue", + }, { label: "Manajemen Pertanyaan", icon: { tb: "TbChecklist" }, @@ -28,13 +35,6 @@ const sidebarMenus: SidebarMenu[] = [ link: "/assessmentRequest", color: "green", }, - { - label: "Manajemen Aspek", - icon: { tb: "TbClipboardText" }, - allowedPermissions: ["permissions.read"], - link: "/aspect", - color: "blue", - }, { label: "Manajemen Permohonan Asesmen", icon: { tb: "TbReport" },